Architecture
Where the code runs
Everything in this extension runs in the browser: there is no server extension, no backend, and no Jupyter server. The notebooks, the file system and the review threads all live in the browser origin.
One asset does come over the network on first load: JupyterLite fetches the Pyodide runtime from a CDN (cdn.jsdelivr.net), which is the JupyterLite default. The demo data is local — data/customers.csv ships with the site — so once the kernel has started, nothing the notebooks do needs the network.
The deployment is served cross-origin isolated (Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: credentialless), which gives the Pyodide worker a real SharedArrayBuffer for synchronous communication instead of the service-worker fallback. That is why the site is deployed on a host that can set response headers; GitHub Pages cannot.
Dependency direction
The extension is layered so that Jupyter semantics never depend on WebMCP, and WebMCP never contains Jupyter logic of its own:
JupyterLab APIs (NotebookPanel, INotebookTracker, sharedModel, sessionContext, ...)
|
v
src/jupyter/* — a thin adapter over those APIs: resolving notebooks,
| reading/writing cells, running cells, focus, hashing,
| serializing outputs, structured errors.
v
semantic operations — the functions in src/jupyter/* ARE the semantic
| operations; they return plain, JSON-serializable
| data (INotebookInfo, ICellSnapshot, ...), not
| WebMCP-shaped results.
v
src/webmcp/* — a thin adapter that turns each semantic operation
into a named tool: JSON Schema input validation,
annotations, and wrapping the result/error into the
`{content, structuredContent}` envelope.src/webmcp/tools.ts is intentionally the only file that both imports src/jupyter/* operations and builds WebMCP tool definitions; every individual tool handler is a few lines of argument parsing followed by one call into src/jupyter/* or src/review/*. This keeps the WebMCP layer thin for two reasons: first, every Jupyter operation is independently testable and independently useful (the extension would still make sense as a plain JupyterLab command-palette feature with no WebMCP at all); second, it keeps the one file that talks to document.modelContext small enough to audit for the things that matter most — no hidden execution paths, no unbounded results, no silently-overwritten edits — without wading through notebook-model plumbing at the same time.
src/review/* is a parallel, independent stack with the same shape: review/model.ts and review/anchors.ts are pure data/algorithm modules with no JupyterLab imports, review/storage.ts is the Jupyter-facing adapter (reads/writes notebook metadata), and the WebMCP comment tools in src/webmcp/tools.ts are thin wrappers around ReviewStore.
System diagram
┌───────────────────────────────────────────────────────────────────┐
│ Browser │
│ │
│ JupyterLab UI <──────────────> live notebook model │
│ │ │ │
│ │ v │
│ │ JupyterLite contents + IndexedDB │
│ │ │ │
│ │ v │
│ │ Pyodide / WebWorker kernel │
│ │ │
│ v │
│ jupyterlite-webmcp (src/jupyter/* + src/review/*) │
│ │ │
│ v │
│ src/webmcp/* ──────> document.modelContext.registerTool(...) │
│ │ │
└───────────────────────────────────┼────────────────────────────────┘
v
compatible browser agentThe live notebook model is the single source of truth. JupyterLab's own UI and jupyterlite-webmcp both read and write it directly; JupyterLite's contents manager persists it to IndexedDB, and the notebook's own kernel messaging talks to the in-browser Pyodide kernel. jupyterlite-webmcp adds no additional storage and no additional execution path: it is another reader/writer of exactly the same model, exposed outward through document.modelContext.
File-by-file
| File | Job |
|---|---|
src/index.ts | Defines and exports the seven plugins (jupyterlite-webmcp:review, :access, :activity, :propose, :panel, :output-selection, :tools); wires the single right-sidebar Agent panel, the review/access/"Ask about…" commands, and the cosmetic markers together, and registers WebMCP tools once app.started resolves. |
src/tokens.ts | The IReviewStore, IActivityLog, IOutputSelectionTracker and IProposeStore Lumino tokens, so a store can be provided by one plugin and required by another. |
src/limits.ts | Centralized numeric bounds (see below) used by every module that serializes notebook data into a tool result. |
src/jupyter/workspace.ts | IJupyterEnv (the app/docManager/tracker/fileBrowser bundle every operation takes), workspace listing, current directory, and open-document paths. |
src/jupyter/paths.ts | Validates and normalizes workspace-relative paths; rejects absolute paths, .. traversal, backslashes, and control characters. |
src/jupyter/notebook.ts | Resolves a notebook panel from an optional path (reusing an already-open panel so reads are always live, never stale disk bytes); notebook/kernel summaries; create and save notebook. Also the single notebook-level access checkpoint: resolveNotebook takes an intent: 'read' | 'write' option and refuses hidden ('none') and read-only notebooks per src/access/notebook.ts, so every tool inherits the policy from this one call. |
src/jupyter/cells.ts | Reads bounded cell snapshots; insert/update/delete cell, each guarded by sourceHash where mutation risks discarding an edit. |
src/jupyter/execution.ts | Runs existing cells on the shared kernel (never an arbitrary source string); interrupt/restart kernel actions. |
src/jupyter/focus.ts | Reads the human's active cell/selection/cursor — withholding id, index and selected text of cells hidden from the agent by 'none' access, exactly like the cell reads do; reveals and focuses a cell, optionally setting an exact selection, using the notebook's native windowed-scroll and editor APIs. |
src/jupyter/outputs.ts | Serializes raw nbformat outputs into bounded, agent-safe JSON: text is ANSI-stripped and byte-bounded, images/binary payloads are represented only by mime type and byte estimate, and a deterministic output fingerprint is computed for change detection. |
src/jupyter/revisions.ts | stableHash, hashCellSource, and computeNotebookRevision — the deterministic, non-cryptographic hashing this project's concurrency guarantees are built on. |
src/jupyter/export.ts | Renders a notebook to a bounded markdown document for jupyter_export_notebook: markdown cells verbatim, code cells as fenced blocks, outputs as fenced text/error blocks with images reduced to a placeholder line. A pure module with no @jupyterlab/* dependency, so it's unit-tested directly. |
src/jupyter/errors.ts | The closed ErrorCode union, the ToolError exception type, and normalizeError, which reduces any thrown value to a plain {error, message, ...} object. |
src/webmcp/schemas.ts | The JSON Schema for every tool's input, keyed by tool name. |
src/webmcp/tools.ts | Builds all 22 IToolDefinitions (21 without an output-selection tracker): argument parsing/validation, then a call into src/jupyter/* or src/review/*, then a plain JSON payload. |
src/webmcp/results.ts | boundJson, okResult, errorResult — bounds a JSON payload's serialized size and builds the {content, structuredContent, isError} envelope. |
src/webmcp/register.ts | WebMCPRegistry: feature-detects document.modelContext, registers every tool exactly once, wraps each tool's execute to normalize errors and record diagnostics, and exposes live registration state for the status bar item. |
src/webmcp/types.ts | IToolDefinition, IWebMCPState, IInvocationRecord — the plain-data shapes the registry and status UI share. |
src/review/model.ts | Pure data model for review threads: IThread/IAnchor/IMessage types, normalizeReview (defensive deserialization of untrusted metadata), and immutable thread-construction helpers (createThread, withMessage, withStatus). |
src/review/anchors.ts | Pure line/column <-> offset conversion and the source-range re-anchoring algorithm (resolveSourceAnchor, makeSourceAnchor). |
src/review/storage.ts | ReviewStore: reads/writes the review metadata key on the live notebook model, lists/creates/replies/resolves/reopens threads, and computes each thread's current anchorStatus against the live notebook. |
src/review/commands.ts | Front-end commands (Add Comment, Comment on Cell, Comment on Output, Show Review Panel) and their context-menu entries, used by a human without any agent involved. |
src/review/panel.tsx | The Comments section of the Agent panel (CommentsSection/CommentsFilter, rendered by src/ui/panel.tsx): lists threads for the current notebook with filters (Open/Resolved/All/Current cell), reply/resolve/reopen controls, and click-to-navigate. |
src/review/markers.ts | Purely cosmetic: toggles a CSS class and data-webmcp-threads/data-webmcp-open-threads attributes on cell DOM nodes that have comment threads, debounced, so the notebook shows where the comments are without opening the panel. |
src/ui/status.ts | WebMCPStatus: an optional status-bar item showing availability and tool count, with a click-to-open diagnostics popover (registered tools, recent invocations). |
src/ui/panel.tsx | WebMcpPanel: the single right-sidebar Agent panel, one React widget tabbing between the Activity, Comments and Access sections — what used to be separate Review and Activity panels plus the missing complete access UI, consolidated behind one small tab bar. |
src/ui/statusText.ts | Pure text logic for the status-bar item (live state, in-flight summary), free of @jupyterlab/* imports so it is unit-tested directly. |
src/ui/popover.ts | Popover: the one floating, anchored panel primitive, used by the cell diff, the failed-run detail and the "Ask about…" affordances; stays anchored through notebook scrolling and windowing. |
src/ui/askAbout.ts | The "Ask about selection" and "Ask about this output" commands and output affordance: they say plainly what context would be shared and make it current, without calling or queueing anything for an agent. |
src/activity/panel.tsx | The Agent panel's Activity section (ActivitySection): who is present (human and agent) and a bounded timeline of what each has just done. |
src/activity/model.ts | ActivityLog: the in-memory, bounded log of what each participant (the human or the browser agent) has just done, plus the in-flight tool calls the markers and status bar show. AGENT_PARTICIPANT/HUMAN_PARTICIPANT and the IActivityEvent shape. |
src/activity/derive.ts | deriveActivity/activityKindFor: turns a completed tool invocation into an activity event with no per-tool wiring elsewhere. Pure and defensive; never throws on a malformed payload. |
src/activity/diff.ts | A small, dependency-free, bounded LCS line diff (diffLines, diffStats) used for the ±N changed before/after view of an agent edit and the Propose-mode diff. |
src/activity/markers.ts | ActivityMarkers: purely cosmetic presence markers on cell and output widgets — the targeted-cell ring while a tool call is in flight, the inline cell-state badge, the ±N changed diff button, and the "Run by Browser agent" output label. |
src/access/overview.ts | AccessOverview: a presentation-only, debounced watcher that derives the per-cell access rows (id, index, type, label, effective access, last provenance entry) for the Access section. |
src/access/notebook.ts | Pure data model plus the Jupyter-facing half of notebook-level agent access control (write/read/none under the same jupyterlite_webmcp metadata key, on the notebook's own metadata): notebookAccessOfPanel/notebookAccessOfContent readers, the human-only setNotebookAccess/writeNotebookAccessToFile writers, and assertNotebookAccessible, the notebook analogue of assertCellAccessible ('none' throws the same NOTEBOOK_NOT_FOUND a missing file would; 'read' under a write intent throws NOTEBOOK_ACCESS_DENIED). |
src/access/panel.tsx | The Agent panel's Access section (AccessSection): the notebook's own access dropdown plus an apply-to-all-cells bulk toggle, then the complete per-cell access list, each row click-to-reveal and click-to-cycle — every write still going through setNotebookAccess/setCellAccess, the same choke points the context-menu shortcuts use. |
src/access/model.ts | Pure data model for per-cell agent access control and provenance: CellAccess/IHistoryEntry types, normalizeCellMetadata (defensive deserialization, mirroring review/model.ts), and appendHistory's bounding/coalescing rule. |
src/access/guard.ts | assertCellAccessible: the single centralized access-control checkpoint every id-addressed cell path runs a cell's access through; cellAccess/setCellAccess/recordCellHistory (read/write the jupyterlite_webmcp cell metadata key, undo-exempt); withAgentAttribution/isAgentAttributed (the scope marker the human-edit listener checks). |
src/access/provenance.ts | ProvenanceTracker: a debounced model listener that attributes a cell's source edits to the human, deferring to whatever an agent tool call already recorded when one is in flight. |
src/access/commands.ts | jupyterlite-webmcp:cycle-cell-access, the only way a cell's access ever changes, plus its cell context-menu entry — a human control with no WebMCP dependency. Also jupyterlite-webmcp:cycle-notebook-access and its file-browser context-menu entry on notebooks: the only way a notebook's access ever changes (live model when open, straight to the file when closed). |
src/access/markers.ts | Purely cosmetic: toggles a CSS class and a native tooltip (access state, plus provenance when known) on cell DOM nodes whose agent access is restricted. |
src/selection/capture.ts | OutputSelectionTracker: records the human's text selection when it lies wholly inside one text output (bounded, null when it crosses outputs or cells or sits in a rich widget). It only prepares context for an explicit handoff; it never contacts the agent. |
src/selection/visible.ts | Filters the output-selection tracker's record through agent access control before the jupyter_get_output_selection tool sees it: a selection inside a 'none' cell or notebook — or one the current notebook cannot verify — reads as null, so the tool can never leak a hidden cell's id, text, or output fingerprint. |
src/propose/store.ts | ProposeStore: the human-only Direct/Propose mode toggle and pending proposals machine — one pending proposal per cell, accept/deny/abort, each settling the Promise the tool call is waiting on. No WebMCP tool can read or change the mode. |
src/propose/tools.ts | proposeUpdateCell: the Propose-mode branch of jupyter_update_cell — validates the write (access + sourceHash) before staging a proposal, waits on ProposeStore, and re-validates the sourceHash on accept before calling the same updateCell Direct mode uses. |
src/propose/commands.ts | jupyterlite-webmcp:toggle-propose-mode / :set-propose-mode, reachable from the Agent panel's mode toggle and the command palette — a human control with no WebMCP dependency. |
src/propose/markers.ts | ProposalMarkers: renders the inline accept/deny banner and diff under the targeted cell for a pending proposal; purely presentational, reusing the same before/after diff rendering as the ±N changed popover. |
The plugins
jupyterlite-webmcp:review
requires: [INotebookTracker]
provides: IReviewStore
jupyterlite-webmcp:access
requires: [INotebookTracker]
optional: [IDefaultFileBrowser]
jupyterlite-webmcp:activity
provides: IActivityLog
jupyterlite-webmcp:propose
provides: IProposeStore
jupyterlite-webmcp:panel
requires: [INotebookTracker, IReviewStore, IActivityLog, IProposeStore]
optional: [ILayoutRestorer]
jupyterlite-webmcp:output-selection
requires: [INotebookTracker]
provides: IOutputSelectionTracker
jupyterlite-webmcp:tools
requires: [INotebookTracker, IDocumentManager, IReviewStore]
optional: [IDefaultFileBrowser, IStatusBar, IActivityLog, IOutputSelectionTracker, IProposeStore]Review is its own plugin, independent of WebMCP, because it is a normal notebook feature in its own right: a human creates, replies to, resolves, reopens, and navigates comments from the Agent panel's Comments tab with no browser agent involved at all, and that must keep working in a browser with no document.modelContext. Structuring it this way also means the tools plugin doesn't need to know anything about comment storage — it just requires the IReviewStore token the review plugin provides and calls its public methods, the same way the panel does. Access, activity, propose and output-selection follow the same split: plain notebook functionality in their own plugins, with only the tools plugin touching document.modelContext, and the panel plugin owning the one consolidated Agent panel that surfaces all three.
Concurrency: read, hash, write
Every cell read returns a sourceHash (a cheap, non-cryptographic digest of the cell's type and source; see src/jupyter/revisions.ts). jupyter_update_cell and jupyter_delete_cell must send that hash back. If the cell changed in the meantime, the write is refused with STALE_CELL, carrying the current hash and a bounded preview, and nothing is written. The agent re-reads and reconciles; a concurrent human edit always wins.
Why correctness never depends on DOM/CSS selectors
Every operation in src/jupyter/* reads and writes through JupyterLab's supported APIs: the notebook's shared model (sharedModel.getSource(), insertCell, deleteCell, setSource), INotebookTracker/NotebookPanel for resolving the active notebook, CodeEditor.IEditor for cursor/selection, and sessionContext/CodeCell.execute for execution. None of this reads CSS classes or DOM structure to determine notebook state, so it keeps working across JupyterLab UI/theme changes and works identically whether or not a cell currently has an on-screen widget.
The one deliberate exception is src/review/markers.ts: it toggles a CSS class (jp-webmcp-hasComments) and two data-* attributes on cell DOM nodes purely so a human can visually see which cells have comments without opening the Agent panel's Comments tab. This is cosmetic presentation layered on top of state that is already authoritative elsewhere (ReviewStore); nothing reads these DOM attributes back as a source of truth.
Bounds (src/limits.ts)
| Constant | Value | Used for |
|---|---|---|
DEFAULT_CELLS_RETURNED | 20 | Default cell count for jupyter_get_cells when no explicit range is given. |
MAX_CELLS_RETURNED | 100 | Hard cap on cells returned by one jupyter_get_cells call. |
MAX_WORKSPACE_ROWS | 100 | Cap on entries returned by jupyter_list_workspace. |
MAX_CELL_SOURCE_BYTES | 25 KiB (25 * 1024) | Cap on one cell's returned source text. |
MAX_TEXT_OUTPUT_BYTES | 10 KiB (10 * 1024) | Cap on one output's serialized text (stream/result/error traceback and evalue), and the shared budget for one output's textData. |
MAX_TOTAL_RESULT_BYTES | 50 KiB (50 * 1024) | Cap on the serialized size of one whole tool result's content text. |
MAX_SELECTED_TEXT_BYTES | 4 KiB (4 * 1024) | Cap, in UTF-8 bytes, on the returned text of the human's current editor selection, a captured output selection, and a comment anchor's selectedText (an oversized anchor keeps its longest prefix that fits, with its stored range shrunk to match). |
MAX_ERROR_STRING_BYTES | 2 KiB (2 * 1024) | Cap on any one string in an error result (errors echo caller input such as a cellId). |
MAX_COMMENT_BODY_BYTES | 8 KiB (8 * 1024) | Cap on one comment message body. |
MAX_COMMENTS_RETURNED | 50 | Cap on threads returned by jupyter_list_comments. |
MAX_OUTPUTS_PER_CELL | 10 | Cap on outputs serialized per cell. |
MAX_ANCHOR_CONTEXT | 80 | Characters of prefix/suffix context captured for source-range re-anchoring. |
MAX_PREVIEW_CHARS | 400 | Length of the source preview included in STALE_CELL errors and comment-thread summaries. |
MAX_SUMMARY_CHARS | 600 | Length of the one-line output summary returned by jupyter_run_cells. |
MAX_CELL_HISTORY_ENTRIES | 20 | Cap on provenance entries kept per cell. |
HISTORY_COALESCE_WINDOW_MS | 60,000 (60s) | Consecutive same-actor/same-action provenance entries within this window collapse into one. |
MAX_CELL_SOURCE_WRITE_BYTES | 256 KiB (256 * 1024) | Cap on a cell source accepted by jupyter_insert_cell/jupyter_update_cell. Deliberately larger than MAX_CELL_SOURCE_BYTES, and an oversized write is rejected outright, never truncated — it's real content the human keeps. |
MAX_NAME_BYTES | 256 | Cap on a notebook/file name argument. |
MAX_CELL_IDS_PER_CALL | 100 | Cap on the number of cell ids accepted in one id-array argument (e.g. jupyter_run_cells's explicit range). |
MAX_EXPORT_BYTES | 40 KiB (40 * 1024) | Cap on the size of a jupyter_export_notebook document, measured JSON-escaped so the whole result stays under MAX_TOTAL_RESULT_BYTES. |
MAX_EXPORT_CELLS | 500 | Cap on the number of cells jupyter_export_notebook walks. |
MAX_COMMENT_MESSAGES_RETURNED | 20 | Cap on messages of one review thread returned to an agent: the first message plus the most recent ones, with the rest counted in omittedMessages. |
Propose mode keeps two constants of its own in src/propose/store.ts: MAX_DENY_REASON_BYTES (2 KiB), the cap on the human's deny reason (longer text is cut on a character boundary, not rejected), and MAX_SETTLED_PROPOSALS (20), how many decided proposals are remembered. Pending proposals are never evicted.
The write-input limits (MAX_CELL_SOURCE_WRITE_BYTES, MAX_NAME_BYTES, MAX_COMMENT_BODY_BYTES), the returned-source and output-text bounds, and the total result bound count UTF-8 bytes, not JavaScript string length.
boundJson (src/webmcp/results.ts) applies MAX_TOTAL_RESULT_BYTES as a final backstop on the serialized content text of every tool result, independent of whichever per-field limits above already applied. If the payload doesn't fit, it first drops trailing items from the 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 dropped, and the root gets truncated: true. Only when no trimming makes it fit is it replaced by a small {truncated: true, reason, maxBytes, partial} envelope, with partial an opaque prefix of the JSON whose length is chosen (by binary search) so the re-escaped envelope itself fits. structuredContent is a convenience copy of the same payload for a client that can consume structured data directly: the payload itself when it fitted, the trimmed copy when it was trimmed, and omitted for the partial envelope, where attaching the original would reintroduce exactly the size the bound exists to prevent.