Skip to content

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:

text
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 ​

text
┌───────────────────────────────────────────────────────────────────┐
│                              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 agent

The 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 ​

FileJob
src/index.tsDefines 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.tsThe IReviewStore, IActivityLog, IOutputSelectionTracker and IProposeStore Lumino tokens, so a store can be provided by one plugin and required by another.
src/limits.tsCentralized numeric bounds (see below) used by every module that serializes notebook data into a tool result.
src/jupyter/workspace.tsIJupyterEnv (the app/docManager/tracker/fileBrowser bundle every operation takes), workspace listing, current directory, and open-document paths.
src/jupyter/paths.tsValidates and normalizes workspace-relative paths; rejects absolute paths, .. traversal, backslashes, and control characters.
src/jupyter/notebook.tsResolves 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.tsReads bounded cell snapshots; insert/update/delete cell, each guarded by sourceHash where mutation risks discarding an edit.
src/jupyter/execution.tsRuns existing cells on the shared kernel (never an arbitrary source string); interrupt/restart kernel actions.
src/jupyter/focus.tsReads 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.tsSerializes 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.tsstableHash, hashCellSource, and computeNotebookRevision — the deterministic, non-cryptographic hashing this project's concurrency guarantees are built on.
src/jupyter/export.tsRenders 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.tsThe closed ErrorCode union, the ToolError exception type, and normalizeError, which reduces any thrown value to a plain {error, message, ...} object.
src/webmcp/schemas.tsThe JSON Schema for every tool's input, keyed by tool name.
src/webmcp/tools.tsBuilds 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.tsboundJson, okResult, errorResult — bounds a JSON payload's serialized size and builds the {content, structuredContent, isError} envelope.
src/webmcp/register.tsWebMCPRegistry: 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.tsIToolDefinition, IWebMCPState, IInvocationRecord — the plain-data shapes the registry and status UI share.
src/review/model.tsPure 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.tsPure line/column <-> offset conversion and the source-range re-anchoring algorithm (resolveSourceAnchor, makeSourceAnchor).
src/review/storage.tsReviewStore: 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.tsFront-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.tsxThe 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.tsPurely 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.tsWebMCPStatus: an optional status-bar item showing availability and tool count, with a click-to-open diagnostics popover (registered tools, recent invocations).
src/ui/panel.tsxWebMcpPanel: 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.tsPure 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.tsPopover: 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.tsThe "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.tsxThe 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.tsActivityLog: 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.tsderiveActivity/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.tsA 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.tsActivityMarkers: 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.tsAccessOverview: 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.tsPure 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.tsxThe 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.tsPure 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.tsassertCellAccessible: 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.tsProvenanceTracker: 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.tsjupyterlite-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.tsPurely 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.tsOutputSelectionTracker: 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.tsFilters 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.tsProposeStore: 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.tsproposeUpdateCell: 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.tsjupyterlite-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.tsProposalMarkers: 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 ​

ts
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) ​

ConstantValueUsed for
DEFAULT_CELLS_RETURNED20Default cell count for jupyter_get_cells when no explicit range is given.
MAX_CELLS_RETURNED100Hard cap on cells returned by one jupyter_get_cells call.
MAX_WORKSPACE_ROWS100Cap on entries returned by jupyter_list_workspace.
MAX_CELL_SOURCE_BYTES25 KiB (25 * 1024)Cap on one cell's returned source text.
MAX_TEXT_OUTPUT_BYTES10 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_BYTES50 KiB (50 * 1024)Cap on the serialized size of one whole tool result's content text.
MAX_SELECTED_TEXT_BYTES4 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_BYTES2 KiB (2 * 1024)Cap on any one string in an error result (errors echo caller input such as a cellId).
MAX_COMMENT_BODY_BYTES8 KiB (8 * 1024)Cap on one comment message body.
MAX_COMMENTS_RETURNED50Cap on threads returned by jupyter_list_comments.
MAX_OUTPUTS_PER_CELL10Cap on outputs serialized per cell.
MAX_ANCHOR_CONTEXT80Characters of prefix/suffix context captured for source-range re-anchoring.
MAX_PREVIEW_CHARS400Length of the source preview included in STALE_CELL errors and comment-thread summaries.
MAX_SUMMARY_CHARS600Length of the one-line output summary returned by jupyter_run_cells.
MAX_CELL_HISTORY_ENTRIES20Cap on provenance entries kept per cell.
HISTORY_COALESCE_WINDOW_MS60,000 (60s)Consecutive same-actor/same-action provenance entries within this window collapse into one.
MAX_CELL_SOURCE_WRITE_BYTES256 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_BYTES256Cap on a notebook/file name argument.
MAX_CELL_IDS_PER_CALL100Cap on the number of cell ids accepted in one id-array argument (e.g. jupyter_run_cells's explicit range).
MAX_EXPORT_BYTES40 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_CELLS500Cap on the number of cells jupyter_export_notebook walks.
MAX_COMMENT_MESSAGES_RETURNED20Cap 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.

Released under the MIT License. Winner of the OpenAI WebMCP Challenge.