WebMCP tool reference
This document describes every tool jupyterlite-webmcp registers with document.modelContext. Descriptions are quoted verbatim from src/webmcp/tools.ts; inputs are drawn from the JSON Schemas in src/webmcp/schemas.ts; outputs and bounds are drawn from the handler implementations in src/jupyter/* and src/review/*.
Result envelope
Every tool invocation returns the same shape (src/webmcp/results.ts):
{
content: [{ type: 'text', text: string }], // JSON, bounded to ~50 KiB
structuredContent?: unknown, // the same payload, when it fits
isError?: boolean // present and true on failure
}On success, content[0].text is the handler's return value JSON-serialized and bounded to LIMITS.MAX_TOTAL_RESULT_BYTES (50 KiB), and structuredContent is that same value as structured data. If the payload does not fit, trailing items are dropped from its largest arrays of objects (cells, threads, entries) until it does: the object that held a trimmed array gets truncated: true and its omittedCount increased by the number of items dropped, the root object gets truncated: true, and structuredContent is the trimmed copy. Only if no trimming makes it fit does content[0].text become a small {truncated: true, reason, maxBytes, partial} envelope (partial is an opaque prefix of the JSON, as long as fits once re-escaped, so the envelope itself never exceeds the cap), with structuredContent omitted entirely — the bound would mean nothing if the unbounded payload were still attached beside it.
On failure, isError is true, structuredContent is the structured error below, and content[0].text is that same error JSON-serialized. Every string in an error (errors often echo the caller's own input, such as a cellId) is clamped to MAX_ERROR_STRING_BYTES (2 KiB of UTF-8); an error still over the total bound is reduced to {error, message}.
Structured error shape
{
error: ErrorCode; // one of the closed set below
message: string; // human-readable
[key: string]: unknown; // optional extra fields, e.g. cellId, expectedSourceHash
}Every thrown ToolError reduces to this shape via normalizeError (src/jupyter/errors.ts). An unexpected exception (not a ToolError) normalizes to INTERNAL_ERROR with its message truncated to 500 characters; no stack trace is ever included.
Error codes
| Code | Meaning |
|---|---|
NO_ACTIVE_NOTEBOOK | No notebookPath was given and there is no notebook currently open (or, for jupyter_list_comments with scope: "current-cell", no active cell). |
NOTEBOOK_NOT_FOUND | The given path does not resolve to an open or existing notebook (or, for jupyter_list_workspace, an existing directory). |
CELL_NOT_FOUND | No cell with the given id exists in the resolved notebook — or it does, but the notebook owner set its agent access to "none" (see "Per-cell agent access control and provenance" below); the two cases are deliberately indistinguishable. |
STALE_CELL | expectedSourceHash did not match the cell's current source hash; the write was refused. |
INVALID_PATH | A path argument was malformed, absolute, escaped the workspace root, or used a backslash; also jupyter_create_notebook's "Could not create a notebook at ..." (see that tool). |
PATH_EXISTS | jupyter_create_notebook would have overwritten an existing file. |
INVALID_CELL_TYPE | An unsupported cell type was requested for jupyter_insert_cell (only code/markdown/raw are valid). |
INVALID_ARGUMENT | A required argument was missing or the wrong type/shape (also used for an unsupported kernel action or insert position, and for a text input over its size limit, with its UTF-8 length as bytes in the details). |
KERNEL_UNAVAILABLE | The notebook has no kernel attached (needed by jupyter_run_cells or jupyter_kernel_action). |
EXECUTION_ERROR | Reserved for execution failures reported through the structured error channel; per-cell execution errors from jupyter_run_cells are instead reported inline in that tool's own result (status: "error", ename/evalue/traceback), not as a thrown ErrorCode. |
ABORTED | The tool invocation's AbortSignal fired before or during the call. |
WEBMCP_UNAVAILABLE | Reserved for the case where WebMCP is not available; the extension only registers tools once it is, so it is not normally observed by a tool caller. |
COMMENT_NOT_FOUND | No review thread with the given threadId exists in the resolved notebook — or it does, but it is anchored to a cell the owner set to "none"; the two cases give the same message and details, and never the hidden cell's id. |
COMMENT_ANCHOR_STALE | A comment anchor could not be validated: the selected text is no longer present in the cell, the output index doesn't exist, or (for source-range creation via anchor.text) the given text was not found in the cell source. |
CELL_ACCESS_DENIED | The notebook owner restricted a "read" cell and the call needed write access (editing or deleting it, or creating, replying to, resolving or reopening a comment on it). jupyter_run_cells raises it only in its up-front check; a cell that becomes read-only mid-call is reported as a no-op result instead. Carries cellId and the effective access in its details. Never thrown for a "none" cell — that yields CELL_NOT_FOUND instead, so the restriction can't be probed for. |
NOTEBOOK_ACCESS_DENIED | The notebook owner restricted the whole notebook to "read" and the call needed write access (editing, deleting, inserting, or running cells, saving, kernel actions, or creating/replying/resolving/reopening comments). Carries path and the effective access in its details. Never thrown for a "none" notebook — that yields NOTEBOOK_NOT_FOUND instead, so the restriction can't be probed for. |
PROPOSAL_ALREADY_PENDING | Propose mode only: jupyter_update_cell was called for a cell that already has an unresolved proposal. Carries cellId and existingProposalId. See docs/propose-mode.md. |
INTERNAL_ERROR | Any unexpected failure, normalized with a truncated message and no stack trace. |
A denied proposal is not reported through this error channel at all — PROPOSAL_DENIED is a normal, non-error result (isError unset), because a human saying no is an expected outcome, not a failure. See docs/propose-mode.md.
Why there is no KERNEL_BUSY
The closed ErrorCode union deliberately does not include KERNEL_BUSY. jupyter_run_cells never checks the kernel's status before submitting a request: the kernel is shared with the human, execution is inherently queued through Jupyter's own messaging protocol, and a single tool call that runs several cells submits them one after another on purpose (cell 2 must be able to queue behind cell 1 while cell 1's "idle" status message is still in flight). There is no moment at which "busy because of someone else's work" can be distinguished from "busy because we just queued the next cell of this very call" without a race — checking kernel status and then submitting is not atomic, and a check-then-throw would either fire spuriously on a tool's own multi-cell run or miss genuinely-contended kernels depending on message timing. Because this architecture queues on the shared kernel by design rather than ever needing to reject a request as "busy," the code never has an honest signal to attach to KERNEL_BUSY, so the code was removed rather than left declared but permanently dead.
AbortSignal behavior
jupyter_run_cells accepts and acts on an AbortSignal (passed through by the WebMCP runtime as options.signal). In Propose mode jupyter_update_cell honors it too: an aborted call cancels its pending proposal. If the signal is already aborted when the tool starts, it throws ABORTED immediately. If it fires while a cell this invocation started is executing, the tool sends a kernel interrupt — but only while that invocation's own execution is in flight; it never interrupts execution the human (or another tool call) started, because the kernel is shared. The interrupted cell keeps its own status (usually error), every remaining cell is reported with status: "abort" and not run (see jupyter_run_cells), and the overall status is aborted. Every other tool runs to completion or throws normally.
Per-cell agent access control and provenance
Every notebook cell may carry a jupyterlite_webmcp metadata object (src/access/model.ts):
{
"access": "none" | "read" | "write", // absent means "write"
"history": [
{ "at": "2026-01-01T00:00:00.000Z", "actor": "human" | "agent", "action": "inserted" | "edited" | "ran" | "deleted", "tool": "jupyter_update_cell" }
]
}Access is set entirely by the human, from the cell context menu (jupyterlite-webmcp:cycle-cell-access, which cycles write -> read -> none -> write) — no WebMCP tool can change it. write (the default) lets the agent read, edit, delete and run the cell; read lets it read the cell's source and outputs but refuses any write or execution; none hides the cell from the agent entirely — not its source, outputs, or even its existence as an addressable id. Every id-addressed cell operation (jupyter_get_cells with explicit cellIds, jupyter_update_cell, jupyter_delete_cell, jupyter_run_cells, jupyter_focus_cell, and the cell a new review comment is anchored to) runs the cell's access through one function, assertCellAccessible (src/access/guard.ts): a "none" cell always yields CELL_NOT_FOUND (indistinguishable from a bad id, so the restriction cannot be probed for), and a "read" cell yields CELL_ACCESS_DENIED only when the call needed write access. An existing review thread anchored to a "none" cell reads as a thread that does not exist (COMMENT_NOT_FOUND, see "Review" below). A non-explicit read (a jupyter_get_cells range, or the focus state jupyter_get_context/jupyter_open_notebook/jupyter_focus_cell report) instead silently omits a "none" cell and reports how many were omitted (hiddenCellCount, hiddenSelectedCellCount, hiddenActiveCell) — never a silent gap the agent has no way to notice.
Provenance is a loose, best-effort attribution trail, not version control: history is bounded to the most recent MAX_CELL_HISTORY_ENTRIES (20) entries, and consecutive entries with the same actor and action within HISTORY_COALESCE_WINDOW_MS (60 seconds) collapse into one, so a burst of typing or a chain of tool calls doesn't blow up the history. Agent-driven changes are recorded by the tool paths themselves (src/jupyter/cells.ts, src/jupyter/execution.ts); a human edit is attributed by a debounced model listener (src/access/provenance.ts) that only fires for genuine source changes and defers to whatever the tool path already recorded when a WebMCP tool call is in flight (withAgentAttribution/isAgentAttributed in src/access/guard.ts). jupyter_get_cells surfaces a compact lastEditedBy/lastEditedAt per cell; jupyter_get_cell_access surfaces each cell's full history.
Both features work with no agent connected at all — the context-menu command, the cell markers, and the provenance listener never touch document.modelContext — and a notebook with no jupyterlite_webmcp cell metadata behaves exactly as it did before this feature existed.
Notebook-level agent access control
Every notebook carries the same three-state policy one level up, under the same jupyterlite_webmcp metadata key, this time on the notebook's own metadata (src/access/notebook.ts), so it travels with the .ipynb file exactly like cell access and review threads do:
write(the default): normal per-cell rules apply.read: the agent may list, open, and read the notebook, but every tool that would mutate it is refused withNOTEBOOK_ACCESS_DENIED— inserting, editing, deleting, or running cells, saving, kernel actions, and creating/replying/resolving/reopening review comments. Navigating (jupyter_open_notebook,jupyter_focus_cell,jupyter_focus_comment) and all read tools keep working.none: the notebook is hidden from the agent entirely. It is omitted fromjupyter_list_workspace(silently — not even a count, so a hidden file is indistinguishable from a file that does not exist) and fromjupyter_get_context'sopenDocuments; resolving it by path throws exactly theNOTEBOOK_NOT_FOUNDa nonexistent path would (same code, same message, same details); a hidden current notebook reads exactly like no notebook being open at all (NO_ACTIVE_NOTEBOOK).
Enforcement lives in one place, resolveNotebook (src/jupyter/notebook.ts takes an intent: 'read' | 'write' option, defaulting to 'read'), plus the two paths that never resolve a notebook: listWorkspace filters hidden files before returning, and getContext filters them out of the workspace summary. Like cell access, notebook access is set entirely by the human — from the file-browser context menu on notebooks (jupyterlite-webmcp:cycle-notebook-access, which cycles write -> read -> none -> write and writes through the live model when the notebook is open, straight to the file when it is not) or from the Agent panel's Access tab (a per-notebook read/write/hidden dropdown plus an "apply to all cells" bulk toggle) — and no WebMCP tool can read or change it. There are no consent prompts anywhere: the owner declares what exists for the agent; the WebMCP client owns any allow-once/allow-always UX.
Context & navigation
jupyter_get_context
- Title: Get notebook context
- Description: "Read the live state of the browser-local Jupyter workspace: the open documents, the current notebook (including whether it has unsaved changes), the kernel status, the active and selected cells, the cursor, and the exact text the user currently has selected. Call this first; the selection is how the user points at something when they say "this"."
- Read/write: read-only (
readOnlyHint: true,untrustedContentHint: true) - Inputs: none (
{}) - Output:ts
{ workspace: { currentDirectory: string; openDocuments: string[] }; notebook: INotebookInfo | null; // null if no notebook is open kernel: IKernelInfo | null; focus: IFocusContext | null; review: { openThreads: number; totalThreads: number } | null; }INotebookInfo:{ path, name, dirty, revision, cellCount }.revisionis a hash over the agent-visible cells only, so an edit to a"none"-access cell does not change it; it is an informational change token, not a write guard (writes are guarded per cell byexpectedSourceHash).cellCountcounts every cell, hidden ones included.IKernelInfo:{ name: string | null, displayName?, status }(statusone ofidle/busy/starting/dead/unavailable/unknown).IFocusContext:{ activeCellId, activeCellIndex, activeCellType, selectedCellIds, hiddenSelectedCellCount, hiddenActiveCell, cursor: {line, column} | null, textSelection: {start, end, text, truncated?} | null }.textSelectionisnullwhen the selection is empty (start === end). Cells the owner restricted to"none"-access never appear by id: a hidden active cell yieldsnullid/index/type/cursor/textSelectionwithhiddenActiveCell: true, and hidden selected cells are counted inhiddenSelectedCellCountinstead of listed — the same "never a silent gap" rule ashiddenCellCount. - Bounds:
textSelection.textbounded toMAX_SELECTED_TEXT_BYTES(4 KiB). - Errors: none thrown; every field degrades to
nullwhen there is no current notebook — including when the current notebook is hidden from the agent (notebookAccess: "none"), which reads exactly like no notebook being open, with hidden documents filtered out ofopenDocuments(see "Notebook-level agent access control" above). - Concurrency: always reads the live model; reflects unsaved edits and the human's current selection at call time.
jupyter_list_workspace
- Title: List workspace files
- Description: "List files and directories in the browser-local workspace. Returns names, paths, types, sizes and modification times, never file contents. Use it to find a notebook before opening it."
- Read/write: read-only (
readOnlyHint: true,untrustedContentHint: true) - Inputs:
Field Type Default pathstring or null workspace root recursiveboolean falselimitinteger, 1-100 100 ( MAX_WORKSPACE_ROWS) - Output:ts
{ path: string; entries: IWorkspaceEntry[]; truncated: boolean; omittedCount: number }IWorkspaceEntry:{ path, name, type, size?, modified? }(typeisdirectory,notebook, orfileas reported by the contents manager). Directories sort before files; both sort alphabetically by name. - Bounds: at most
limit(capped at 100) entries;recursiveperforms a breadth-first walk that still respects the same cap across the whole walk, not per directory. - Errors:
NOTEBOOK_NOT_FOUNDif the root path doesn't exist;INVALID_PATHif the root path is a file, not a directory (a non-existent recursive subdirectory is silently skipped rather than erroring, since it may have been deleted mid-walk). - Notebook visibility: notebooks the owner hid (
notebookAccess: "none") are omitted silently — never listed, never counted — so they are indistinguishable from files that do not exist (see "Notebook-level agent access control" above). - Concurrency: none needed; read-only.
jupyter_open_notebook
- Title: Open a notebook
- Description: "Open a notebook from the workspace and bring it to the front, optionally scrolling to a specific cell. This visibly changes what the user is looking at."
- Read/write: write, UI state (
readOnlyHint: false,untrustedContentHint: true) - Inputs:
Field Type Default pathstring (required) — cellIdstring or null none activateboolean true - Output:ts
{ notebook: INotebookInfo; kernel: IKernelInfo; focus: IFocusContext; // the resulting focus, incl. the cell activated by `cellId` review: { openThreads, totalThreads }; } - Bounds: none beyond the notebook/kernel/review summary shapes above.
- Errors:
NOTEBOOK_NOT_FOUNDif no file exists atpath, if it is not a notebook — or if the notebook is hidden from the agent, which is deliberately indistinguishable from the missing-file case;CELL_NOT_FOUNDifcellIdis given but doesn't exist once opened. - Concurrency: if the notebook is already open, the existing panel (and its unsaved edits) is reused rather than the file being re-opened from disk.
jupyter_create_notebook
- Title: Create a notebook
- Description: "Create a new, empty notebook in the browser-local workspace and open it. Refuses to overwrite an existing file."
- Read/write: write (
readOnlyHint: false,untrustedContentHint: false) - Inputs:
Field Type Default namestring (required) — ( .ipynbappended if missing)directorystring or null workspace root kernelstring or null application default kernel - Output:
{ path: string; notebook: INotebookInfo; kernel: IKernelInfo } - Bounds:
nameis limited toMAX_NAME_BYTES(256); a longer name is rejected withINVALID_ARGUMENT. - Errors:
INVALID_ARGUMENTifnameis empty/blank;PATH_EXISTSif a file already exists at the target path (nothing is overwritten);INVALID_PATHwith the messageCould not create a notebook at "<path>".(details{ path }) if the contents manager fails to create or rename the file (a strayUntitlednotebook is deleted again) — and, identically, if the existing file at the target path is a notebook hidden from the agent (notebookAccess: "none"), so creating over a hidden notebook is indistinguishable from a genuine creation failure and never reveals it with aPATH_EXISTS;INTERNAL_ERRORin the (expected never to occur) case the created file cannot be opened as a notebook.kernelis matched case-insensitively against installed kernel names, then kernel languages; an unmatched request silently falls back to the application default rather than erroring. - Concurrency: the existence check and creation are not atomic against another concurrent creator, but this mirrors normal contents-manager usage elsewhere in JupyterLab.
Notebook structure
jupyter_get_cells
- Title: Read notebook cells
- Description: "Read cells from the live notebook model, including edits the user has not saved yet. Each cell comes back with its stable id and a sourceHash; you must pass that hash back to edit or delete the cell. Outputs are bounded and only included when asked for."
- Read/write: read-only (
readOnlyHint: true,untrustedContentHint: true) - Inputs:
Field Type Default notebookPathstring or null current notebook cellIdsstring[] none — takes priority over the index range when given startIndexinteger >= 0 0 endIndexinteger >= 0 startIndex + 20(DEFAULT_CELLS_RETURNED)includeSourceboolean trueincludeOutputsboolean false - Output:ts
{ notebook: INotebookInfo; cells: ICellSnapshot[]; truncated: boolean; omittedCount: number; hiddenCellCount: number }ICellSnapshot:{ id, index, type, source?, sourceTruncated?, sourceHash, executionCount?, outputs?, outputsTruncated?, metadata?, lastEditedBy?, lastEditedAt? }.metadatais only included when the cell's JSON-encoded metadata is non-empty and at most 512 characters.lastEditedBy("human"or"agent") andlastEditedAt(ISO timestamp) come from the cell's provenance history (see "Per-cell agent access control and provenance" below) and are omitted when the cell has no recorded history. Each serialized output (src/jupyter/outputs.ts) ists{ outputType, executionCount?, name?, text?, html?, ename?, evalue?, traceback?, textData?: Array<{ mimeType, text, truncated? }>, media?: Array<{ mimeType, bytes, included: false }>, truncated? }textcarriestext/plain(or a stream's text) andhtmlistext/htmlreduced to plain text. Every other MIME type in anexecute_resultordisplay_dataoutput is either text-like —text/*,application/json, any+jsonor+xmltype,application/javascript,application/x-latex(so markdown, LaTeX and JSON outputs) — and returned as text intextData, or binary (everyimage/*type,image/svg+xmlincluded, PDFs, anything else) and returned only as amediaplaceholder giving its approximate size, never its content. - Bounds: at most
MAX_CELLS_RETURNED(100) cells per call regardless of how many were requested;sourcebounded toMAX_CELL_SOURCE_BYTES(25 KiB of UTF-8); at mostMAX_OUTPUTS_PER_CELL(10) outputs per cell. Output text is ANSI-stripped and bounded in UTF-8 bytes:text,tracebackandevaluetoMAX_TEXT_OUTPUT_BYTES(10 KiB) each,htmlto half that,enameto 400 bytes (MAX_PREVIEW_CHARS), and all of one output'stextDataentries share a single 10 KiB budget (an entry that no longer fits is left out). A cut field ends in…[truncated], and any cut or omission sets the output'struncated. - Errors:
CELL_NOT_FOUNDif any id incellIdsdoesn't exist, or is a cell the notebook owner hid from the agent (access: "none") — the two are indistinguishable on purpose (see "Per-cell agent access control and provenance" below);NO_ACTIVE_NOTEBOOK/NOTEBOOK_NOT_FOUNDfrom notebook resolution. - Concurrency: always reads the live model;
sourceHashis exactly what a subsequentjupyter_update_cell/jupyter_delete_cellmust supply asexpectedSourceHash. - Cell visibility: when reading a range (no explicit
cellIds), a cell the notebook owner hid from the agent is silently omitted fromcells— never a partial or misleading entry — and counted inhiddenCellCount, which is always present (even when zero) so an agent that sees fewer cells than expected can tell "that's all there is" apart from "some cells were withheld".
jupyter_get_cell_access
- Title: Read cell agent access
- Description: "Report what a connected agent may currently do with each cell (write, read, or none) plus its full provenance history, and how many cells in range are hidden entirely. The notebook owner controls this per cell from the cell context menu; there is no tool to change it. Use this to explain to the user why you are not touching a cell."
- Read/write: read-only (
readOnlyHint: true,untrustedContentHint: true) - Inputs:
Field Type Default notebookPathstring or null current notebook cellIdsstring[] none — takes priority over the index range when given startIndexinteger >= 0 0 endIndexinteger >= 0 startIndex + 20(DEFAULT_CELLS_RETURNED) - Output:ts
{ notebook: INotebookInfo; cells: ICellAccessSummary[]; truncated: boolean; omittedCount: number; hiddenCellCount: number }ICellAccessSummary:{ cellId, index, access, history }, wherehistoryis the cell's full bounded provenance trail (at mostMAX_CELL_HISTORY_ENTRIES, 20, entries:{ at, actor, action, tool? }). - Bounds: at most
MAX_CELLS_RETURNED(100) cells per call; at most 20 history entries per cell. - Errors:
CELL_NOT_FOUNDif any id incellIdsdoesn't exist or is hidden from the agent;NO_ACTIVE_NOTEBOOK/NOTEBOOK_NOT_FOUNDfrom notebook resolution. - There is no tool to set a cell's access. That is the human's control over the shared document, exercised from the cell context menu (
jupyterlite-webmcp:cycle-cell-access); no WebMCP tool callssetCellAccess.
jupyter_insert_cell
- Title: Insert a cell
- Description: "Insert a new cell into the live notebook, above or below a reference cell. The cell is visible to the user immediately. It is not executed: run it with jupyter_run_cells as a separate, explicit step."
- Read/write: write (
readOnlyHint: false,untrustedContentHint: true) - Inputs:
Field Type Default notebookPathstring or null current notebook referenceCellIdstring or null the active cell position"above"|"below""below"cellType"code"|"markdown"|"raw""code"sourcestring ""activateboolean true - Output:
{ notebook: INotebookInfo; cell: ICellSnapshot }(cell snapshot always includes source). - Bounds:
sourceoverMAX_CELL_SOURCE_WRITE_BYTES(256 KiB of UTF-8) is rejected withINVALID_ARGUMENT(details{ bytes }, the source's UTF-8 length), never truncated; the returned cell follows the standard cell snapshot bounds. - Errors:
INVALID_CELL_TYPEfor an unsupportedcellType;INVALID_ARGUMENTfor an unsupportedposition;CELL_NOT_FOUNDifreferenceCellIdis given but doesn't exist, or if the human deletes or hides the new cell before the call returns (the result is re-read by the new cell's id, never by its original index). - Concurrency: if the notebook is empty, the cell is appended regardless of
referenceCellId/position. Whenactivateis true (the default) a markdown cell withsourceis immediately rendered.
jupyter_update_cell
- Title: Update a cell
- Description: "Replace the source of a visible notebook cell in the live model. Requires the sourceHash returned by a previous read, so an unsaved human edit can never be overwritten by accident: if the cell changed, the write is refused with a STALE_CELL error containing the current hash and a preview. Does not run or save the cell. When the human has switched the notebook to Propose mode, this call does not apply immediately: it stages a reviewable diff in the notebook UI and waits for the human; it does not resolve until they accept (applied, same as Direct mode) or deny it (a normal, non-error result carrying their reason, coded PROPOSAL_DENIED), or the call is aborted. It is auto-denied the same way, with a reason, if the cell is deleted or the notebook is closed or renamed first."
- Read/write: write (
readOnlyHint: false,untrustedContentHint: true) - Inputs (all required except
notebookPath):Field Type notebookPathstring or null cellIdstring sourcestring — the complete replacement source expectedSourceHashstring — sourceHashfrom a previous read - Output (Direct mode, and Propose mode once accepted):
{ notebook: INotebookInfo; cell: ICellSnapshot }, plus (Propose mode only)status: 'accepted'andproposalId. - Output (Propose mode, denied): a normal, non-error result —ts
{ status: 'denied', code: 'PROPOSAL_DENIED', proposalId, cellId, reason: string | null }reasonis the human's free-text explanation typed into the inline deny control, ornullwhen they left it blank. An auto-denied proposal (its cell was deleted, or the notebook's last view closed, or the notebook was renamed or moved, before anyone decided) returns this same result, withreasonsaying which; seedocs/propose-mode.md. - Bounds:
sourceoverMAX_CELL_SOURCE_WRITE_BYTES(256 KiB of UTF-8) is rejected withINVALID_ARGUMENT(details{ bytes }), never truncated — in Propose mode before any proposal is created; standard cell-snapshot bounds on the returned cell; the denyreasonis cut to 2 KiB of UTF-8 (MAX_DENY_REASON_BYTESinsrc/propose/store.ts). - Errors:
INVALID_ARGUMENTifsourceisn't a string orexpectedSourceHashis missing (a missingsourceis refused, never silently treated as "empty the cell");CELL_NOT_FOUND(also thrown for a"none"-access cell);CELL_ACCESS_DENIEDfor a"read"-access cell;STALE_CELL—ts({ error: 'STALE_CELL', message: 'Cell changed since it was read.', cellId, expectedSourceHash, currentSourceHash, currentSourcePreview }currentSourcePreviewbounded toMAX_PREVIEW_CHARS, 400 characters); in Propose mode only,PROPOSAL_ALREADY_PENDINGwhen the same cell already has an unresolved proposal ({ cellId, existingProposalId }) — seedocs/propose-mode.mdfor why a second proposal is refused outright rather than queued; andABORTEDif the caller'sAbortSignalfires before the human decides. - Concurrency: this is the read-hash-write protocol described in
docs/architecture.md. In Propose mode, the size, access andsourceHashchecks all run before the proposal is created, and thesourceHashis checked twice — once then (so a doomed write fails immediately instead of sitting in front of the human unnecessarily), and again on accept (so a human edit made while the proposal was pending still wins). Does not run or save the notebook; the notebook becomes dirty naturally through the normal shared-model change. - Propose mode: see
docs/propose-mode.mdfor the full design — the Direct/Propose toggle, the inline accept/deny UI, the one-pending- proposal-per-cell rule, and howAbortSignalis honored while a proposal is genuinely pending.
jupyter_delete_cell
- Title: Delete a cell
- Description: "Delete a visible notebook cell. Requires the sourceHash from a previous read; a cell the user has edited since then is not deleted."
- Read/write: write (
readOnlyHint: false,untrustedContentHint: true) - Inputs (all required except
notebookPath):notebookPath,cellId,expectedSourceHash. - Output:ts
{ notebook: INotebookInfo; deletedCellId: string; activeCellId: string | null }activeCellIdis alsonullwhen the cell that became active after the delete is"none"-access. - Bounds: none.
- Errors:
INVALID_ARGUMENTifexpectedSourceHashis missing;CELL_NOT_FOUND(also thrown for a"none"-access cell);CELL_ACCESS_DENIEDfor a"read"-access cell;STALE_CELL(same shape asjupyter_update_cell, message "Cell changed since it was read; it was not deleted."). - Concurrency: same read-hash-write protocol as
jupyter_update_cell.
Execution
jupyter_run_cells
Title: Run notebook cells
Description: "Execute cells that already exist in the notebook, using the browser-local kernel the user shares. Select them with cellIds, or with an explicit contiguous startIndex/endIndex range; if no selector is given, the active cell runs. The user sees the busy state, the execution counts and the outputs. There is no way to run an arbitrary source string: to compute something new, insert a visible cell first and then run it."
Read/write: write (
readOnlyHint: false,untrustedContentHint: true)Inputs:
Field Type Default notebookPathstring or null current notebook cellIdsstring[] none — use these explicit cells in order; mutually exclusive with startIndex/endIndexstartIndexinteger >= 0 none — inclusive first index of a contiguous range; must be paired with endIndexendIndexinteger >= 0 none — exclusive end index of a contiguous range; must be paired with startIndexstopOnErrorboolean trueOutput:
ts{ status: 'ok' | 'error' | 'aborted'; notebook: INotebookInfo; results: Array<{ cellId: string; index: number; status: 'ok' | 'error' | 'abort' | 'no-op'; executionCount?: number | null; outputSummary: string; // bounded one-line summary ename?: string; evalue?: string; traceback?: string; // on error }>; }Markdown cells run as a no-op that renders them; non-code/non-markdown (
raw) cells run as a no-op noting they aren't executed; an empty code cell runs as a no-op without contacting the kernel.Bounds:
outputSummarybounded toMAX_SUMMARY_CHARS(600 characters);ename/evalue/tracebackpass through the same output serializer asjupyter_get_cells(ANSI-stripped;evalueandtracebackbounded toMAX_TEXT_OUTPUT_BYTES,enameto 400 bytes).Selection: provide either
cellIdsor bothstartIndexandendIndex, never both. A range uses zero-based indexes with an inclusive start and an exclusive end, and may contain at mostMAX_CELL_IDS_PER_CALL(100) cells. With no selector, the active cell is run. The range is resolved in notebook order at invocation time and every result preserves that order. All targets are access-checked before the first one runs, so a hidden ("none") or read-only ("read") cell fails the call without partially executing an earlier target.Targets that change mid-call: targets are remembered by cell id, and each one is re-resolved and re-checked for write access right before it runs, because the human can edit the notebook while earlier cells execute:
- a target that was deleted or hidden (
"none") since the call started — or whose notebook widget is missing or out of step with the model — is skipped asstatus: "no-op"withindex: -1. ItscellIdis the id the agent passed incellIds, or""when it was addressed by range or as the active cell, so a hidden cell reads exactly like a deleted one. A cell hidden while it ran is reported the same way, and its outcome does not affect the overall status; - a target that became
"read"-access is skipped asstatus: "no-op", keeping itscellIdand currentindex(the agent can still see it); - notebook-level access is re-checked too: if the owner makes the whole notebook read-only mid-call, every remaining target is skipped as a
no-opthe same way; if the owner hides it, the call fails exactly as a hidden notebook would (NO_ACTIVE_NOTEBOOK, orNOTEBOOK_NOT_FOUNDwhennotebookPathwas given) and returns nothing about cells already run.
Skips never count as errors, so they do not trigger
stopOnError.- a target that was deleted or hidden (
Errors:
INVALID_ARGUMENTif only one range endpoint is provided, both selector forms are provided, a range endpoint is negative or non-integer,endIndexis beforestartIndex, or the range exceeds the per-call cell limit; if no selector is given and there is no active cell;KERNEL_UNAVAILABLEif any requested cell that still exists is a code cell and no kernel is attached;CELL_NOT_FOUNDif, at call start, a requested cell doesn't exist or is"none"-access (including the active cell, when no selector was given);CELL_ACCESS_DENIEDif, at call start, a requested cell is"read"-access;ABORTEDif the signal had already fired. Per-cell execution failures are not thrown; they are reported inline asstatus: "error"withename/evalue/tracebackon that cell's result, andstopOnError(defaulttrue) stops the remaining queued cells without throwing.Concurrency / AbortSignal: honors
AbortSignalas described above — an abort interrupts only execution this invocation started, via a kernel interrupt, and never touches work the human launched manually. Cells run strictly in the given order. Once the signal fires, the interrupted cell keeps its own status (usuallyerror), every remaining target (other than one skipped as above) gets anabortentry (it is not run, andstopOnErrordoes not cut the list short), and the overallstatusisaborted.
jupyter_focus_cell
- Title: Focus a cell
- Description: "Scroll to a cell, select it, and optionally place the cursor or select an exact range of its source using the notebook editor’s own selection. Use it to point the user at the code you are talking about. Changes only what is on screen."
- Read/write: view-state only (
readOnlyHint: false,untrustedContentHint: true) - Inputs:
Field Type Notes notebookPathstring or null defaults to current notebook cellIdstring (required) cursor{line, column}ignored if selectionis also givenselection{start: {line,column}, end: {line,column}}takes priority over cursor - Output:
{ notebook: INotebookInfo; focus: IFocusContext }(seejupyter_get_contextfor theIFocusContextshape). - Bounds: none.
- Errors:
CELL_NOT_FOUNDifcellIddoesn't exist in the resolved notebook, or is"none"-access. A"read"-access cell can be focused — focusing never changes cell content, so it's neverCELL_ACCESS_DENIED. The cell is checked before anything on screen changes, so a bad or hiddencellIdnever brings the notebook to the front. - Concurrency: activates the notebook panel, scrolls the target cell into view (notebooks are windowed, so a far-off-screen cell may need to be scrolled to before it has a live editor), then focuses the editor and applies the selection or cursor. This is purely a view-state change: it never mutates cell content.
jupyter_save_notebook
- Title: Save the notebook
- Description: "Save the notebook to the browser-local workspace. The live in-memory model is authoritative, so this is only needed when the user wants the file on disk updated."
- Read/write: write (
readOnlyHint: false,untrustedContentHint: false) - Inputs:
{ notebookPath?: string | null } - Output:
{ saved: true; path: string; dirty: boolean } - Bounds: none.
- Errors: standard notebook-resolution errors (
NO_ACTIVE_NOTEBOOK/NOTEBOOK_NOT_FOUND). - Concurrency: uses the normal document
context.save()path; no tool ever saves automatically after another mutation.
jupyter_kernel_action
- Title: Interrupt or restart the kernel
- Description: "Interrupt or restart the browser-local kernel for a notebook. The kernel is shared with the user, so an interrupt also stops anything they started. Restarting discards every in-memory variable."
- Read/write: write (
readOnlyHint: false,untrustedContentHint: false) - Inputs:
{ notebookPath?: string | null; action: "interrupt" | "restart" }(actionrequired) - Output:ts
{ action: string; notebookPath: string; kernel: IKernelInfo; message: string }messageforrestartexplicitly states in-memory variables are lost. - Bounds: none.
- Errors:
KERNEL_UNAVAILABLEifaction: "interrupt"is requested with no kernel attached;INVALID_ARGUMENTfor anyactionother thaninterrupt/restart. - Concurrency: only
interruptandrestartare implemented (V1 scope); both act on the one kernel shared with the human, so either affects anything the human had running.
Review
All seven comment tools resolve the target notebook the same way as the cell tools (notebookPath or the current notebook) and operate through ReviewStore (src/review/storage.ts), the same store the Agent panel's Comments tab uses.
Hidden cells. A thread anchored to a cell the owner set to "none" does not exist as far as the agent can tell: it is left out of jupyter_list_comments and of the thread counts every tool reports, and jupyter_get_comment, jupyter_reply_comment, jupyter_resolve_comment, jupyter_reopen_comment and jupyter_focus_comment all fail on it with exactly the COMMENT_NOT_FOUND an unknown threadId gives (same message, same details, never the hidden cell's id). A thread on a "read" cell can be listed, read and focused, but replying to, resolving or reopening it is refused with CELL_ACCESS_DENIED.
Message cap. Every tool that returns a whole thread (get, create, reply, resolve, reopen) returns at most MAX_COMMENT_MESSAGES_RETURNED (20) of its messages, and at most about 25 KiB of them: always the first message (the original comment), then as many of the most recent ones as fit, in order. The number left out (they sit between the first and the kept recent ones) is reported beside the thread as omittedMessages.
IThread: { id, status, createdAt, updatedAt, anchor, messages[] }, each message { id, author: { kind, name }, createdAt, body }.
jupyter_list_comments
- Title: List review comments
- Description: "List the review threads stored in a notebook. Threads are an ordinary notebook feature the user can also create, reply to and resolve by hand; they are saved in the notebook file. Each summary says whether the thread still points at live code or has become orphaned."
- Read/write: read-only (
readOnlyHint: true,untrustedContentHint: true) - Inputs:
Field Type Default notebookPathstring or null current notebook status"open"|"resolved"|"all""open"scope"notebook"|"current-cell""notebook"limitinteger, 1-50 50 ( MAX_COMMENTS_RETURNED) - Output:tsThreads are sorted newest-created-first.
{ notebookPath: string; counts: { openThreads: number; totalThreads: number }; // agent-visible threads only threads: Array<{ threadId, status, createdAt, updatedAt, messageCount, anchor: { kind, cellId, cellIndex, selectedText?, outputIndex?, state, outputChanged? }, lastMessage: { author, createdAt, body } | null; // body bounded to 400 chars }>; truncated: boolean; omittedCount: number; } - Bounds: at most
limit(capped at 50) threads, and only as many as fit in a byte budget just underMAX_TOTAL_RESULT_BYTES, so a long list stops cleanly; either cut setstruncatedand counts the rest inomittedCount.lastMessage.bodyis bounded toMAX_PREVIEW_CHARS(400 characters). - Errors:
NO_ACTIVE_NOTEBOOKifscope: "current-cell"is requested with no active cell; the standard notebook-resolution errors. - Concurrency:
anchor.state/outputChangedare computed live against the current notebook on every call, so a thread's anchor status always reflects the notebook as it exists right now, not as it existed when the thread was created.
jupyter_get_comment
- Title: Read a review thread
- Description: "Read one review thread: its messages (the first and the most recent when long), the anchor, whether the anchor still resolves, and the code or output it is attached to as it exists now."
- Read/write: read-only (
readOnlyHint: true,untrustedContentHint: true) - Inputs:
{ notebookPath?: string | null; threadId: string }(threadIdrequired) - Output:tsFor an
{ notebookPath: string; thread: IThread; // messages capped, see "Message cap" above omittedMessages: number; anchorStatus: IAnchorStatus; // kind, cellId, cellExists, cellIndex, state, range?, text?, outputIndex?, outputChanged? context: { cell?: ICellSnapshot }; // present only if the anchored cell still exists hiddenCellCount: 0; // always 0; kept for result-shape compatibility }outputanchor,context.cellincludes outputs; for other kinds it includes source only. Long threads keep the first message and the most recent ones, as described above. - Bounds: the embedded
cellfollows the same bounds asjupyter_get_cells. - Errors:
COMMENT_NOT_FOUNDifthreadIddoesn't exist in the resolved notebook, or its anchor cell is"none"-access. - Concurrency: same live anchor-status computation as
jupyter_list_comments.
jupyter_create_comment
- Title: Create a review comment
- Description: "Create a review thread anchored to a whole cell, to an exact range of a cell’s source, or to one of a cell’s outputs. This is the same kind of comment the user creates from the Comments tab of the Agent panel, so use it to leave observations without editing their notebook."
- Read/write: write (
readOnlyHint: false,untrustedContentHint: true) - Inputs (
anchorandmessagerequired):ts{ notebookPath?: string | null; anchor: { kind: 'cell' | 'source-range' | 'output'; // required cellId: string; // required selection?: { start: {line,column}, end: {line,column} }; text?: string; // source-range: exact substring to attach to (alternative to selection) outputIndex?: number; // output: which output of the cell (default 0) }; message: string; } - Output:
{ notebookPath: string; thread: IThread; omittedMessages: number; counts: { openThreads, totalThreads } } - Bounds:
messageoverMAX_COMMENT_BODY_BYTES(8 KiB of UTF-8) andanchor.textoverMAX_SELECTED_TEXT_BYTES(4 KiB) are rejected withINVALID_ARGUMENT(details{ key, bytes }), never truncated; for asource-rangeanchor, the capturedselectedTextand prefix/suffix context follow the same bounds as human-created anchors (MAX_SELECTED_TEXT_BYTES/MAX_ANCHOR_CONTEXT). - Errors:
INVALID_ARGUMENTifanchor.kind/anchor.cellId/messageare missing or too large, or if asource-rangeanchor supplies neitheranchor.textnoranchor.selection;CELL_NOT_FOUNDifcellIddoesn't exist or is"none"-access;CELL_ACCESS_DENIEDif the cell is"read"-access (this check applies only to agent-authored comments; a human commenting from the Comments tab is never blocked by their own restriction);NOTEBOOK_ACCESS_DENIEDif the notebook is"read";COMMENT_ANCHOR_STALEifanchor.textisn't found in the cell's current source, if asource-rangeanchor's resulting selected text can't be validated against the live cell, or if anoutputanchor'soutputIndexdoesn't exist on that cell (run it first). Agent-authored comments are tagged withAGENT_AUTHOR({ kind: 'agent', name: 'Browser agent' }) — no other vendor identity is invented. - Concurrency: anchors are validated against the live notebook at creation time, exactly like a human-created comment; nothing here reads or writes cell source.
jupyter_reply_comment
- Title: Reply to a review thread
- Description: "Append a message to an existing review thread. The user sees it in the Comments tab of the Agent panel next to their own messages."
- Read/write: write (
readOnlyHint: false,untrustedContentHint: true) - Inputs:
{ notebookPath?: string | null; threadId: string; message: string }(both required) - Output:
{ notebookPath: string; thread: IThread; omittedMessages: number } - Bounds:
messageoverMAX_COMMENT_BODY_BYTES(8 KiB of UTF-8) is rejected withINVALID_ARGUMENT. - Errors:
COMMENT_NOT_FOUND(unknown thread, or its anchor cell is"none"-access);CELL_ACCESS_DENIEDif the anchor cell is"read"-access;NOTEBOOK_ACCESS_DENIEDif the notebook is"read";INVALID_ARGUMENTifmessageis empty, blank or too large. - Concurrency: appends to the thread's message list; does not touch its anchor or status.
jupyter_resolve_comment
- Title: Resolve a review thread
- Description: "Mark a review thread resolved, optionally adding a closing message. The history is preserved and the user can reopen it."
- Read/write: write (
readOnlyHint: false,untrustedContentHint: true) - Inputs:
{ notebookPath?: string | null; threadId: string; resolutionMessage?: string | null }(threadIdrequired) - Output:
{ notebookPath: string; thread: IThread; omittedMessages: number } - Bounds:
resolutionMessageoverMAX_COMMENT_BODY_BYTES(8 KiB of UTF-8) is rejected withINVALID_ARGUMENT. - Errors:
COMMENT_NOT_FOUND(unknown thread, or its anchor cell is"none"-access);CELL_ACCESS_DENIEDif the anchor cell is"read"-access;NOTEBOOK_ACCESS_DENIEDif the notebook is"read";INVALID_ARGUMENTifresolutionMessageis too large. - Concurrency: sets
status: 'resolved'; the full message history is preserved (nothing is deleted), and the thread can be reopened at any time.
jupyter_reopen_comment
- Title: Reopen a review thread
- Description: "Reopen a resolved review thread, preserving its history."
- Read/write: write (
readOnlyHint: false,untrustedContentHint: true) - Inputs:
{ notebookPath?: string | null; threadId: string }(threadIdrequired) - Output:
{ notebookPath: string; thread: IThread; omittedMessages: number } - Bounds: none.
- Errors:
COMMENT_NOT_FOUND(unknown thread, or its anchor cell is"none"-access);CELL_ACCESS_DENIEDif the anchor cell is"read"-access;NOTEBOOK_ACCESS_DENIEDif the notebook is"read". - Concurrency: sets
status: 'open'; no message is appended (unlikejupyter_resolve_comment's optionalresolutionMessage).
jupyter_focus_comment
- Title: Focus a review thread
- Description: "Scroll to what a review thread is attached to and select it, so the user can see exactly which code or output is under discussion. Changes only what is on screen."
- Read/write: view-state only (
readOnlyHint: false,untrustedContentHint: true) - Inputs:
{ notebookPath?: string | null; threadId: string }(threadIdrequired) - Output:ts
{ notebookPath: string; threadId: string; anchorStatus: IAnchorStatus; notebook: INotebookInfo } - Bounds: none.
- Errors:
COMMENT_NOT_FOUND(unknown thread, or its anchor cell is"none"-access);COMMENT_ANCHOR_STALEif the anchored cell no longer exists at all (cellIndex === null) — note this is distinct from an orphaned source-range anchor (text not found but the cell still exists), which instead resolves and reveals the cell without a specific text selection. A"read"cell can be focused. Both checks run before anything on screen changes. - Concurrency: activates the notebook, reveals the anchored cell, and (for a
source-rangeanchor that still resolves to a range) focuses the editor and applies that selection. Purely a view-state change.
Export
jupyter_export_notebook
- Title: Export the notebook
- Description: "Export the notebook as a portable markdown document: markdown cells verbatim, code cells as fenced code blocks, and (by default) their text and error outputs, with images represented only by a placeholder, never embedded. Use this to hand the notebook to another tool (upload it, email it, put it in a document) without a manual export."
- Read/write: read-only (
readOnlyHint: true,untrustedContentHint: true) - Inputs:
Field Type Default notebookPathstring or null current notebook format"markdown""markdown"(the only value today; the enum exists so more formats can be added later)includeOutputsboolean true - Output:ts
{ notebookPath: string; document: string; truncated: boolean; cellCount: number; hiddenCellCount: number; }documentrenders markdown cells verbatim; code cells as fenced```pythonblocks; and, whenincludeOutputsis true, each code cell's text/stream output, error tracebacks, and otherwise the first non-empty text-like (textData) entry, as fenced blocks. Every fence is longer than any run of backticks in its content, so content containing```can't close it early. An image or other binary output is never embedded: it becomes a single placeholder line,!\[output\]\(<mime type>, <N> bytes — not included\). The rendering is implemented insrc/jupyter/export.ts, a pure module with no@jupyterlab/*dependency, so it is unit-tested directly (tests/unit/export.spec.ts). - Bounds:
documentbounded toLIMITS.MAX_EXPORT_BYTES(40 KiB, measured JSON-escaped, so quote- or newline-heavy notebooks still fit the total result bound withstructuredContentintact); at mostLIMITS.MAX_EXPORT_CELLS(500) cells are walked, in notebook order; either bound setstruncated: true. Text/error outputs go through the same serializer (and the sameMAX_TEXT_OUTPUT_BYTESbound) asjupyter_get_cells. - Errors: standard notebook-resolution errors (
NO_ACTIVE_NOTEBOOK/NOTEBOOK_NOT_FOUND);INVALID_ARGUMENTifformatis not"markdown". - Cell visibility: respects per-cell agent access exactly like
jupyter_get_cells: a"none"-access cell is omitted from the document entirely — never even a placeholder — and counted inhiddenCellCount, which is always present (even when zero). - Concurrency: always reads the live model, including unsaved edits; read-only, so it never marks the notebook dirty.
Output selection
jupyter_get_output_selection
- Title: Read the selected output
- Description: "Read the text the user last selected inside a rendered cell output, if any is currently recorded. Returns null when nothing is selected, the selection crossed cells or notebook chrome, or it no longer matches the output it was taken from."
- Read/write: read-only (
readOnlyHint: true,untrustedContentHint: true) - Inputs: none (
{}) - Output: the tracker's current selection record, or
null:ts{ cellId: string; outputIndex: number; text: string; range?: { start: number; end: number }; outputFingerprint: string; capturedAt: string; } | null - Registration: conditional in
buildToolsitself — its third argument, anOutputSelectionTracker(src/selection/capture.ts), is optional, and this tool is only added when one is supplied.src/index.tsalways wires one in (thejupyterlite-webmcp:output-selectionplugin), so the shipped extension registers all 22 tools; a build that omits the tracker registers 21, without this one. The tracker records a bounded output-selection record when a non-empty browser selection falls wholly inside one notebook output — never an arbitrary page selection — and isnullwhenever it crosses cells, includes notebook chrome, or can't be represented as bounded text. - Errors: none thrown.
- Cell visibility: the record is filtered through agent access control (
src/selection/visible.ts) before it is returned: a selection inside a cell — or a notebook — the owner hid (access: "none") reads asnull, never its cell id, text, or output fingerprint, and a stale record the current notebook cannot attribute to a visible cell isnulltoo. Selections in read-only cells stay visible; reads are permitted there. - Concurrency: read-only. The record is re-checked against the live notebook on every call: if the output it was taken from has since been replaced or removed (for example by a re-run), so its
outputFingerprintno longer matches, the result isnull.