@niivue/nv-mcp 0.0.0-stage → 1.0.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +357 -2
  2. package/dist/browser/animate.d.ts +3 -0
  3. package/dist/browser/bindings.d.ts +69 -0
  4. package/dist/browser/client.d.ts +118 -0
  5. package/dist/browser/controls.d.ts +102 -0
  6. package/dist/browser/data.d.ts +23 -0
  7. package/dist/browser/index.d.ts +29 -0
  8. package/dist/browser/instances.d.ts +15 -0
  9. package/dist/browser/liveData.d.ts +18 -0
  10. package/dist/browser/marks.d.ts +3 -0
  11. package/dist/browser/meshes.d.ts +51 -0
  12. package/dist/browser/params.d.ts +44 -0
  13. package/dist/browser/pick.d.ts +14 -0
  14. package/dist/browser/scene.d.ts +28 -0
  15. package/dist/browser/settings.d.ts +3 -0
  16. package/dist/browser/signals.d.ts +15 -0
  17. package/dist/browser/spread.d.ts +13 -0
  18. package/dist/browser/view.d.ts +745 -0
  19. package/dist/browser/viewport.d.ts +3 -0
  20. package/dist/browser.js +3959 -0
  21. package/dist/controls-BTfCAJqr.js +418 -0
  22. package/dist/controls-zeMsFpTF.js +17 -0
  23. package/dist/controls.d.ts +98 -0
  24. package/dist/data-CL9gSbH4.js +14 -0
  25. package/dist/data.d.ts +28 -0
  26. package/dist/index.d.ts +20 -0
  27. package/dist/index.js +120 -0
  28. package/dist/limits.d.ts +14 -0
  29. package/dist/names.d.ts +12 -0
  30. package/dist/planes.d.ts +106 -0
  31. package/dist/protocol.d.ts +127 -0
  32. package/dist/regions.d.ts +43 -0
  33. package/dist/server/args.d.ts +42 -0
  34. package/dist/server/bridge.d.ts +126 -0
  35. package/dist/server/context.d.ts +70 -0
  36. package/dist/server/http.d.ts +52 -0
  37. package/dist/server/index.d.ts +14 -0
  38. package/dist/server/tools-animate.d.ts +46 -0
  39. package/dist/server/tools-controls.d.ts +92 -0
  40. package/dist/server/tools-data.d.ts +33 -0
  41. package/dist/server/tools-extras.d.ts +65 -0
  42. package/dist/server/tools-instances.d.ts +30 -0
  43. package/dist/server/tools-layers.d.ts +186 -0
  44. package/dist/server/tools-marks.d.ts +105 -0
  45. package/dist/server/tools-settings.d.ts +51 -0
  46. package/dist/server/tools-volumes.d.ts +49 -0
  47. package/dist/server/tools.d.ts +786 -0
  48. package/dist/server.js +1949 -0
  49. package/dist/settings-D2XtDHT1.js +508 -0
  50. package/dist/settings.d.ts +100 -0
  51. package/dist/uikit/index.d.ts +30 -0
  52. package/dist/uikit.js +406 -0
  53. package/dist/views-CCRKirN5.js +144 -0
  54. package/dist/views.d.ts +52 -0
  55. package/package.json +60 -4
package/README.md CHANGED
@@ -1,3 +1,358 @@
1
- # Temporary Holding Version
1
+ # @niivue/nv-mcp
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ An [MCP](https://modelcontextprotocol.io) server for a [NiiVue](https://github.com/niivue) page, so an agent can load volumes, move the crosshair, cut planes, find atlas regions and take screenshots of a scene a person is looking at.
4
+
5
+ An agent connects to the server over streamable HTTP; the page keeps a WebSocket open to the same server; each tool call is written to the page as one JSON request and answered with one JSON response. The server holds no anatomy and no scene of its own, so it stays small and the page stays the one place the state lives.
6
+
7
+ The package has four entry points, and nothing in it knows about the app that hosts it:
8
+
9
+ | Entry | Runs in | Holds |
10
+ |---|---|---|
11
+ | `@niivue/nv-mcp` | both | The wire messages, the plane arithmetic, region matching and the AAL spoken-name table |
12
+ | `@niivue/nv-mcp/server` | Bun | The bridge that knows tabs by id, the core tools, `startServer` |
13
+ | `@niivue/nv-mcp/browser` | the page | The client that keeps the socket open, the handlers that answer the core tools from a NiiVue instance, and `bindControls` |
14
+ | `@niivue/nv-mcp/uikit` | the page | `uikitControls`, a control surface that draws an agent's controls on the canvas with `@niivue/uikit` |
15
+
16
+ An app adds its own tools as an *extension* without touching the core. `apps/demo-mcp` in this repository is a complete page and server.
17
+
18
+ ## Installation
19
+
20
+ ```bash
21
+ bun add @niivue/nv-mcp
22
+ ```
23
+
24
+ The server side runs under [Bun](https://bun.sh), because it uses `Bun.serve` for the HTTP and WebSocket ends together. The browser side is plain DOM and WebSocket. `@niivue/niivue` is an optional peer dependency: the browser entry is written against its 1.0 API but imports nothing from it, so any object with the same member names will do (see `View` in `browser/scene.ts`). `@niivue/uikit` is an optional peer dependency too, needed only by the `uikit` entry.
25
+
26
+ ## Usage
27
+
28
+ ### The server
29
+
30
+ ```ts
31
+ import { startServer } from '@niivue/nv-mcp/server'
32
+
33
+ startServer({
34
+ name: 'my-niivue-app',
35
+ version: '0.1.0',
36
+ pageUrl: 'http://localhost:8091',
37
+ bridge: { noTabHint: 'Open http://localhost:8091 in a browser.' },
38
+ })
39
+ ```
40
+
41
+ That listens on `127.0.0.1:4242` (both configurable) and serves three paths:
42
+
43
+ | Path | What |
44
+ |---|---|
45
+ | `/mcp` | The streamable HTTP endpoint an agent's client connects to. Stateless: each request gets its own `McpServer`, all sharing one bridge. |
46
+ | `/app` | The WebSocket the page keeps open |
47
+ | `/` | A plain-text status page listing the connected tabs, with `*` on the one that answers |
48
+
49
+ `startServer` returns the running server with its `bridge`, `url`, `appUrl` and a `stop()`. Pass `log` to route its lines somewhere other than the console, `bridge` options to tune timeouts or the message an agent reads when no page is connected, `pageUrl` so `new_tab` can hand out the address that opens the page as a named tab, `instructions` for what an agent reads when it connects (what the page shows and can fetch, which the tools cannot know), and a `Bridge` of your own if you wire the HTTP yourself.
50
+
51
+ Loopback keeps the network out but not other web pages: any site open in the same browser can ask it to open a socket to `127.0.0.1`, and a hostname an attacker controls can resolve there. So `/app` refuses a page whose `Origin` is not loopback (`localhost`, `127.0.0.1` or `[::1]` on any port; a client that sends no `Origin`, which is not a browser, is let in), and `/mcp` and the status page `/` refuse a request whose `Host` header does not name this server. A page served from elsewhere goes in `allowedOrigins`; a name for the server other than loopback goes in `allowedHosts`, with its port:
52
+
53
+ ```ts
54
+ startServer({
55
+ allowedOrigins: ['https://viewer.example.org'],
56
+ allowedHosts: ['niivue.local:4242'],
57
+ })
58
+ ```
59
+
60
+ Calls to one tab go one at a time, in the order they were made. The page's handlers all change the one scene, so an agent that fires several tools together gets each answered against the scene the one before left rather than racing them.
61
+
62
+ ### The page
63
+
64
+ ```ts
65
+ import NiiVue from '@niivue/niivue'
66
+ import {
67
+ AgentClient,
68
+ coreHandlers,
69
+ type NiiVueHost,
70
+ sceneState,
71
+ } from '@niivue/nv-mcp/browser'
72
+
73
+ const nv = new NiiVue()
74
+ await nv.attachTo('gl1')
75
+ await nv.loadVolumes([{ url: '/volumes/mni152.nii.gz' }])
76
+
77
+ const host: NiiVueHost = { view: nv }
78
+ const client = new AgentClient(coreHandlers(host), {
79
+ state: () => sceneState(host),
80
+ onStatus: (connected) => {
81
+ status.textContent = connected ? `agent connected (tab ${client.id})` : 'no agent server'
82
+ },
83
+ })
84
+ client.attach()
85
+ ```
86
+
87
+ Only `view` is required. The core works on a plain NiiVue with no atlas, with `list_regions` and `go_to_region` declining politely. The other hooks are where an app adds what NiiVue does not know:
88
+
89
+ ```ts
90
+ const host: NiiVueHost = {
91
+ view: nv,
92
+ atlas: () => loadAtlas(), // regions, regionAt, valueAt, nearestIn over millimetres
93
+ atlasApplies: () => isMni, // false on a scan the atlas does not fit
94
+ beforeAnswer: () => fitCanvas(), // anything to do before reading the scene
95
+ moved: (frac) => sample(frac), // called after a navigation lands
96
+ describe: () => whereWeAre(), // the prose for where_am_i and the announcement
97
+ announce: (text) => say(text), // how the page tells the person
98
+ loaded: ({ mni }) => { isMni = mni },
99
+ extraState: () => ({ light }), // state the server should watch across a reload
100
+ planeName: () => currentCutName(), // the page's own name for the plane
101
+ labels: (labels) => pins.setLabels(labels), // draws set_labels' labels, an empty list clears
102
+ controls: memoryControls(), // where add_control puts its widgets
103
+ data: dataPalette(catalog), // the data an agent can load by id
104
+ }
105
+ ```
106
+
107
+ `controls` is a `HostedControls`: a `ControlSurface`, with `add`, `update`, `remove`, `clear` and `list` over the controls an agent asks for, or a `BoundControls` from `bindControls`, whose `add` and `update` may return a promise. Each control is a `ControlSpec` of one of the `CONTROL_KINDS` (button, toggle, slider, menu, select, segmented, number, text, textarea, dialog, color, file) in a grid cell or at a position on the canvas, with a `bind` naming what it drives. A page without `controls` declines the control tools. Two surfaces come with the package:
108
+
109
+ - **`memoryControls()`** keeps the controls and draws nothing. It is enough to list them in the page and to run the tools in a test.
110
+ - **`uikitControls(nv, { font })`** from `@niivue/nv-mcp/uikit` draws each control on the NiiVue canvas with the `@niivue/uikit` widget for its kind. It registers its own overlay renderer, so it needs a NiiVue that is attached, and a font such as `await loadDefaultFont()`.
111
+
112
+ Either surface can be wrapped in `bindControls` so the controls drive the page:
113
+
114
+ ```ts
115
+ import { bindControls } from '@niivue/nv-mcp/browser'
116
+ import { uikitControls } from '@niivue/nv-mcp/uikit'
117
+ import { loadDefaultFont } from '@niivue/uikit'
118
+
119
+ host.controls = bindControls(uikitControls(nv, { font: await loadDefaultFont() }), {
120
+ view: nv,
121
+ actions: {
122
+ reload: { description: 'Loads the template again.', run: () => loadTemplate() },
123
+ },
124
+ onError: (error, event) => say(`${event.id}: ${error.message}`),
125
+ })
126
+ ```
127
+
128
+ A `bind` names one of these:
129
+
130
+ | Bind | Drives |
131
+ |---|---|
132
+ | a setting, such as `gamma` or `crosshairColor` | That NiiVue setting, as `get_options` names it. A slider takes the setting's bounds, a select its choices, a color control its colour |
133
+ | `view.slice`, `view.layout`, `view.radiological` | The slice type, the multiplanar layout and the radiological flag, by the names `set_view` uses |
134
+ | `volume.<i>.<prop>` | A property of the volume at index `i`: `opacity`, `colormap`, `colormap_negative`, `cal_min`, `cal_max`, `cal_min_neg`, `cal_max_neg`, `frame`, `invert`, `colorbar`, `nearest`, `transparent_below_cal_min`, `atlas_outline`, `modulate_alpha` or `spread` (how far its bricks are spread apart in the 3D render, 1 to 4, tiling it 3 by 3 by 3 first when it is one texture) |
135
+ | `mesh.<i>.<prop>` | A property of the mesh at index `i`, by the names `set_mesh` takes: `opacity`, `shader`, `visible`, `colorbar`, `legend` |
136
+ | `mesh.<i>.tract.<option>` | An option of the tract at index `i`, as `set_mesh` takes it: `fiberRadius`, `fiberSides`, `minLength`, `decimation`, `colormap`, `colormapNegative`, `colorBy`, `calMin`, `calMax`, `calMinNeg`, `calMaxNeg`, `fixedColor`. A select bound to `colorBy` takes `direction`, `global`, `fixed` and the tract's scalars (`dpv:<name>`, `dps:<name>`); one bound to `group` takes `all` or one of the tract's groups, shown alone in one flat colour |
137
+ | `mesh.<i>.connectome.<option>` | An option of the connectome at index `i`: `nodeColormap`, `nodeColormapNegative`, `nodeMinColor`, `nodeMaxColor`, `nodeScale`, `edgeColormap`, `edgeColormapNegative`, `edgeMin`, `edgeMax`, `edgeScale` |
138
+ | `dialog.<id>` | Opens that dialog when a button or menu item is pressed. Add the dialog first with `open: false`, so it waits hidden |
139
+ | `data.<id>` | Loads that entry of the page's data palette when a button or menu item is pressed. Pass the palette to `bindControls` as `data`. `coreHandlers` connects the palette to the core loaders; a page that wraps a loader calls `palette.connect` with its own map after |
140
+ | `action.<name>` | One of the page's `actions`. A button or menu runs it when pressed, a value control when its value is committed, a file picker with the files, a dialog with the button that closed it |
141
+
142
+ The binding works both ways. A control the person moves writes its target: a slider or colour control as it moves, any other once the value is committed. A value changed elsewhere, by the person in NiiVue or by an agent's `set_options`, is read back before the next frame and shown on the control. A control bound with no range or choices takes them from its target, and starts at the target's value. A write the person makes that fails, at once or when NiiVue finishes it later, goes to `onError`, or to the console without one. A write an agent makes through `add_control` or `set_control` is waited for: when NiiVue finishes it later, `add` and `update` return a promise, so the control appears or changes only once the write is done. A failure rejects the promise and becomes the tool's error. NiiVue may have set the target before it failed: a failed `add_control` adds no control, and a failed `set_control` makes none of the change asked for and shows on the control what the target now holds. A write that succeeds also shows what the target holds when it finishes, so a change the person made meanwhile is not hidden. A write that has not finished after `writeWait` milliseconds (`WRITE_WAIT_MS`, 10000) fails the call the same way, so a stalled GPU cannot hold the controls for good. That is inside the server's 15 s call timeout (`CALL_TIMEOUT_MS`), so the agent hears this error rather than that the page did not answer; a page that sets `writeWait` should keep it under that too. After that failure the controls take changes again. NiiVue may still finish the write later: the target then holds the agent's value and its control, if one was there, shows it on the next frame; a failed `add_control` still adds no control. When writes overlap, NiiVue runs the queued upload under the first caller, so a failure may be reported to the other call (or to `onError`). While such a write is unfinished, the other agent changes to the controls (add, set, remove, remove all) are turned away with an error that says to call again, so none can land between the write and its control. A `volume.<i>` or `mesh.<i>` bind follows only the volume or mesh it was bound to: once that one is removed, reordered, reloaded or replaced at its index, the control neither shows nor drives the one there now; bind the control again. `capabilities` lists the bind forms and the page's actions as `controlBindings`.
143
+
144
+ `urls` defaults to `agentUrls()`: `/agent` on the page's own origin, then the server directly on port 4242. The client retries with a backoff that settles at half a minute, so the order the two are started in does not matter. An address that neither opens nor refuses within five seconds is closed and the next one tried, so a proxy that hangs cannot keep the page from the server.
145
+
146
+ ### Connecting an agent
147
+
148
+ For Claude Code:
149
+
150
+ ```bash
151
+ claude mcp add --transport http my-niivue-app http://127.0.0.1:4242/mcp
152
+ ```
153
+
154
+ [docs/connecting-agents.md](docs/connecting-agents.md) has the same for the Claude desktop app, Codex CLI, Gemini CLI, VS Code, Cursor, Windsurf, stdio-only clients through `mcp-remote`, and a program of your own with the TypeScript or Python SDK, plus how to check the connection with the MCP Inspector and what the common failures mean.
155
+
156
+ The page's first address, `/agent` on its own origin, is for browsers that let a page reach one origin only, such as the pane inside Claude's desktop app. It needs the dev server to proxy it. In Vite:
157
+
158
+ ```ts
159
+ server: {
160
+ proxy: {
161
+ '/agent': {
162
+ target: 'ws://127.0.0.1:4242',
163
+ ws: true,
164
+ rewrite: (path) => path.replace(/^\/agent/, '/app'),
165
+ },
166
+ },
167
+ },
168
+ ```
169
+
170
+ A page served over HTTPS cannot open `ws://127.0.0.1` at all; the proxy is the way through for that too.
171
+
172
+ Run Vite under Node for this (`bunx vite`, not `bunx --bun vite`). Vite's WebSocket proxy goes through http-proxy's upgrade path, which hangs under Bun's `node:http`: the socket neither opens nor closes, and the page only reaches the server through its second address. Building with `--bun` is fine; only the dev server is affected.
173
+
174
+ ## The core tools
175
+
176
+ | Tool | Input | What it does |
177
+ |---|---|---|
178
+ | `list_tabs` | | The connected tabs: id, title, address, when each connected and which one answers |
179
+ | `use_tab` | `id` | Makes one tab the one that answers every later call |
180
+ | `new_tab` | | Makes up an id for a tab that is not open yet and gives the address that opens the page as that tab, `pageUrl` with `?tab=<id>` on it |
181
+ | `load_volume` | `url`, `name?`, `colormap?`, `mni?` | Loads a volume from an address the page can fetch, replacing what is shown. `mni` says whether the atlas applies; guessed from the name when left out. Reports the name and the volume's bounds in millimetres. It cannot read a file on the person's computer by its path: for one, add a file control bound to the page's action that opens files (see `capabilities` `controlBindings`) and ask the person to pick it |
182
+ | `add_overlay` | `url`, `name?`, `labels?`, `colormap?`, `opacity?`, `cal_min?`, `cal_max?`, `colormap_negative?`, `cal_min_neg?`, `cal_max_neg?`, `colorbar?` | Draws another volume over whatever the page shows, keeping it, whether the page opened with it or `load_volume` brought it. `labels: freesurfer` draws a segmentation with FreeSurfer's names and colours, and `labels` as the address of a label table JSON (`R`, `G`, `B` and `labels` arrays, as NiiVue reads them) draws any other atlas with its own; otherwise a colormap with a display window (`cal_min`, `cal_max`), a second colormap and window for negative values, and a colorbar. A label map has no window, so `cal_min` and `cal_max` are refused with `labels`. A new `load_volume` clears the overlays. Like `load_volume`, it cannot read a file on the person's computer by its path |
183
+ | `where_am_i` | | The crosshair in millimetres and fractions, the volumes shown and how each is drawn, the plane cut, the camera, the view layout, the instances and global camera when `set_instances` has laid some out, the page's description of the place, and whatever state the app adds. Says which tab answered |
184
+ | `list_regions` | `query?` | The atlas regions the page can navigate to: label, spoken name, centroid in millimetres, voxel count. `query` filters by label or name |
185
+ | `go_to_region` | `region`, `plane?` | Moves the crosshair to a region's centroid, turns the camera to face the cut, cuts a plane through the point, and announces the place. `plane` is a side, a slice name or `current`. An ambiguous name fails and lists the candidates |
186
+ | `go_to_point` | `mm?`, `vox?`, `plane?`, `label?` | Moves the crosshair to a point in the volume's world millimetres, or to a voxel of the base volume, cuts a plane through it facing the camera, and announces it, prefixed with `label` when given. The same move as `go_to_region`, for any space |
187
+ | `set_clip_plane` | `plane?`, `depth?`, `azimuth?`, `elevation?`, `index?`, `planes?`, `face?` | Cuts the volume with a whole plane named for the side it takes off, or `off`; or at any `azimuth` and `elevation`; or several planes at once with `planes`, each `[depth, azimuth, elevation]`. `depth` is NiiVue's, clamped to plus or minus 1.5; `index` picks one of the six planes; `face` turns the camera to look at the cut, on by default |
188
+ | `set_camera` | `azimuth?`, `elevation?`, `pan_2d?`, `render_pan?`, `pivot?`, `center_on?`, `global?` | Points the render camera, pans the slices (`pan_2d`, NiiVue's `[x, y, z, zoom]`) or the render, sets or clears the point the render turns about, centres the render on a point in millimetres, or places the global camera that looks at the global3d instances `set_instances` lays out with `global` (`position` in scene units, `yaw`, `pitch` and `fov` in degrees, `near`, `far`). Only what is given changes |
189
+ | `set_instances` | `instances?`, `camera?`, `clear?` | Draws the loaded volumes as up to 100 instances, each with an `id` unique in the list. A `global3d` instance (the default, unless only `bounds` is given) places a volume in one 3D world, seen by the global camera: `position` in scene units, `scale` as the volume's longest side (1 by default; three numbers stretch it), `orientation` as turns about x, y and z in degrees. A `canvas` instance is a tile drawn as the usual render of the base volume (a tile naming another volume is refused, since NiiVue draws the base in every tile): `bounds` `[x1, y1, x2, y2]` as fractions of the canvas from the lower left. Each names its `volume` by index or name (the base volume by default). `camera` places the global camera in the same call; `clear` goes back to the usual layout. NiiVue keeps one texture per file for every loaded volume in this layout, so an instance of a volume loaded from the same file as any other loaded volume (laid out or not) is refused; close the other copy first. Many instances of one volume are fine |
190
+ | `animate` | `azimuth?`, `elevation?`, `zoom?`, `render_pan?`, `pan_2d?`, `crosshair_mm?`, `clip_planes?`, `volumes?`, `meshes?`, `duration_ms?`, `easing?`, `angles?`, `interrupt_on_input?`, `wait?`, `stop?` | Moves the scene smoothly to a target with NiiVue's `animateTo`: the render camera, its zoom and pan, the 2D pan and zoom, the crosshair, clip planes (`index`, `depth`, `azimuth`, `elevation`), each volume's `cal_min`, `cal_max`, `opacity` and 4D `frame`, and each mesh's `opacity`. Only what is given moves. `duration_ms` is capped at 10000 so the move ends before the call times out; `wait: false` answers as soon as it starts; a new move ends the one under way, and `stop` ends it where it is. A move needs a volume or a mesh loaded, and a volume target a volume; `stop` works whatever is loaded. Reports how the move ended (`finished`, `stalled` when the page drew no frames and jumped to the target, `input` when the person took over, `failed` with the parts NiiVue could not land) with the camera, cut and crosshair afterwards |
191
+ | `set_volume` | `volume?`, `colormap?`, `colormap_negative?`, `opacity?`, `cal_min?`, `cal_max?`, `cal_min_neg?`, `cal_max_neg?`, `colormap_type?`, `transparent_below_cal_min?`, `frame?`, `invert?`, `colorbar?`, `nearest?`, `atlas_outline?`, `modulate_alpha?`, `labels?`, `modulate?`, `load_all_frames?`, `auto_window?`, `affine?`, `reset_affine?`, `transform?`, `chunk_grid?`, `spread?`, `v1?` | Changes how one loaded volume is drawn, leaving the rest: the colormaps for positive and negative values, the opacity, the display windows, how the window is drawn below its minimum, the frame of a 4D volume, an inverted colormap, its colorbar, nearest-neighbour sampling, an atlas outline, a label table (`labels`, as `add_overlay` takes it), modulation by another volume, loading every frame of a deferred 4D volume, a recomputed window, its voxel-to-world affine set outright or reset, or a translation, rotation and scale applied to it, or its tiling into bricks and how far they are spread apart in the 3D render (`spread`, an exploded view; a volume not yet chunked is tiled 3 by 3 by 3 first), or a vector field (three float frames, or a tensor file's stored directions) drawn as direction colours (`v1`, with `flip` per axis, in a call of its own; one way, so reload to undo). `volume` is an index as `where_am_i` lists them or a name; the base otherwise. Reports the volume as drawn now, with `chunkGrid` and `spread` when it is tiled, and `superseded` when a control or another call spread it later while the call waited (the later spread wins, and a `chunk_grid` of null in the same call is skipped) |
192
+ | `set_labels` | `labels?`, `dim_others?`, `clear?` | Draws text labels tied by a line to a point each: an atlas region (its centroid, its spoken name as the text unless `text` is given) or a point in `mm` with its own `text`. On a volume tiled into bricks each label outlines the brick holding its point (the page gets the corners as `boxMM`), and `dim_others` sets the opacity of the bricks holding no label. Each call replaces the labels before it, and the dimming; `clear` takes them down. Needs the page's `labels` hook; a page without one declines |
193
+ | `set_view` | `slice?`, `layout?`, `mosaic?`, `show_render?`, `radiological?`, `colorbar?` | Sets what the canvas shows: one slice orientation, all three with the render (`multiplanar`), the render alone, or `none`, which draws no slice and gives the whole canvas to the signal graph; how the multiplanar tiles are arranged and whether the render tile joins them; a NiiVue mosaic string; radiological convention; the colorbar. Only what is given changes. Reports the whole layout |
194
+ | `screenshot` | `max_width?` | Draws the scene now and returns the canvas as a PNG, scaled down to fit. On a volume tiled into bricks it waits for the bricks to arrive first, up to 8 seconds, so the picture is never a part of the volume; and it waits, up to 4 seconds, for the render backend to finish an upload such as a 4D frame change, which would otherwise leave the frame blank. Refuses while the tab is in the background, where the browser draws nothing |
195
+
196
+ Every tool below `new_tab` also takes `tab?`, the id of the tab to ask; without it the answering tab is asked, as the Tabs section explains.
197
+
198
+ Every reply is a line of prose for the agent to read, then the JSON the page returned. Failures are tool errors with the page's own message.
199
+
200
+ ### More on volumes
201
+
202
+ | Tool | Input | What it does |
203
+ |---|---|---|
204
+ | `nudge_crosshair` | `vox` | Steps the crosshair a whole number of voxels along each of the base volume's axes and reports where it lands |
205
+ | `transform_volume` | `name`, `volume?`, `options?`, `replace?` | Runs one of NiiVue's registered volume transforms (`capabilities` lists them with their options) on a loaded volume and adds the result on top of the stack, removing the source when `replace` is set |
206
+ | `update_volume_data` | `volume?`, one of `values`, `url` or `from_volume`, `auto_window?` | Writes new voxel values into a loaded volume in place, keeping its header, colormap and window (`auto_window` recomputes the window). Values are raw, in the volume's datatype and voxel order before `scl_slope`/`scl_inter`, a value per channel for RGB or RGBA, for every frame or only the frame shown. `url` fetches them as little-endian bytes, gzipped or not, which suits anything near the 4 MB cap on a call. A volume drawn in bricks is refused, as are values its datatype would truncate, wrap or overflow. A volume removed, replaced or tiled into bricks while its values were fetched is left alone, and the call fails. One removed while they were written is reported as `removed` rather than described |
207
+ | `remove_volume` | `volume?`, `all?` | Unloads one volume, or every volume with `all`. Reports the volumes that remain |
208
+ | `reorder_volume` | `volume`, `move` | Moves a volume `up`, `down`, to the `top` or to the `bottom` of the stack. The bottom volume is the base that sets the space |
209
+ | `describe_volume` | `volume?`, `stats?`, `mask?`, `mask_labels?`, `drawing?`, `affine?` | One volume in full: how it is drawn, its dimensions, its label table, its voxel statistics (over the whole volume, within a mask volume's non-zero voxels or some of its labels, or within the drawing or some of its pen values), and its affine when asked |
210
+
211
+ ### Settings and capabilities
212
+
213
+ | Tool | Input | What it does |
214
+ |---|---|---|
215
+ | `get_options` | `names?`, `describe?` | Reads NiiVue's settings by their own names: crosshair, colours, fonts, 3D rendering, drawing pen, drag behaviour and the rest. Without `names` it describes every setting the page has, with its kind, its choices or bounds and what it does |
216
+ | `set_options` | `options` | Changes any of those settings, several at once. A choice is given by its word (a drag mode, a pen shape, a render mode), a colour as `[r, g, b, a]` 0 to 1. Every value is checked before any is set |
217
+ | `capabilities` | | What this page's NiiVue offers: its backend, which tool features it supports by group, which settings it has, its colormaps, drawing colormaps and mesh shaders, its volume transforms with their options, and the file types it reads and writes. With controls, it also lists the control kinds and what they can bind to, and with a data palette its entries as `data`. A page whose NiiVue lacks a feature refuses that tool in words and answers every other |
218
+ | `add_colormap` | `name?`, `R?`, `G?`, `B?`, `A?`, `I?`, `labels?`, `url?` | Adds a colormap from its stops, or fetches one from an address as a NiiVue colormap JSON |
219
+ | `set_font` | `atlas`, `metrics` | Loads the font NiiVue draws its text with, from an atlas PNG and a metrics JSON |
220
+ | `set_custom_layout` | `tiles?`, `clear?` | Places tiles on the canvas by hand, each a slice orientation or the render at `[left, top, width, height]` as fractions, in place of the ordinary layout; `clear` goes back to it |
221
+
222
+ ### Meshes
223
+
224
+ | Tool | Input | What it does |
225
+ |---|---|---|
226
+ | `load_mesh` | `url`, `name?`, `replace?`, `opacity?`, `color?`, `shader?`, `slice_shader?`, `visible?`, `colorbar?`, `legend?`, `layers?` | Loads a surface, tract or connectome over the volumes, with overlay layers when given; `replace` drops the meshes shown once it has loaded. Like `load_volume`, it cannot read a file on the person's computer by its path |
227
+ | `list_meshes` | | The meshes shown, each with how it is drawn and its layers, a tract's `groups` and `scalars` (each as `colorBy` names it, with its range), and the shaders the page knows |
228
+ | `set_mesh` | `mesh?`, `name?`, `opacity?`, `color?`, `shader?`, `slice_shader?`, `visible?`, `colorbar?`, `legend?`, `tract?`, `connectome?` | Changes how a mesh is drawn, leaving the rest. `tract` takes NiiVue's tract options (`fiberRadius`, `fiberSides`, `minLength`, `decimation`, `colormap`, `colormapNegative`, `colorBy`, `calMin`, `calMax`, `calMinNeg`, `calMaxNeg`, `fixedColor`, `groupColors`) and reports the tract's groups; `connectome` takes the connectome options (`nodeColormap`, `nodeColormapNegative`, `nodeMinColor`, `nodeMaxColor`, `nodeScale`, `edgeColormap`, `edgeColormapNegative`, `edgeMin`, `edgeMax`, `edgeScale`). Any other key, or an empty object, is refused |
229
+ | `update_mesh_positions` | `mesh?`, one of `values`, `url` or `from_mesh` | Moves a triangulated mesh's vertices in place, x, y and z in millimetres for each, keeping its triangles and layers. `url` fetches them as little-endian float32 bytes, gzipped or not. Tracts and connectomes are refused, as are positions float32 cannot hold. A mesh removed or replaced while its values were fetched is left alone, and the call fails |
230
+ | `remove_mesh` | `mesh?`, `all?` | Unloads one mesh, or every mesh |
231
+ | `add_mesh_layer` | `url`, `mesh?`, `name?`, `colormap?`, `colormap_negative?`, `cal_min?`, `cal_max?`, `cal_min_neg?`, `cal_max_neg?`, `opacity?`, `colorbar?`, `invert?`, `transparent_below_cal_min?`, `additive?`, `outline_width?` | Draws a per-vertex overlay (a curvature, a thickness, a statistic) on a mesh |
232
+ | `set_mesh_layer` | `mesh?`, `layer?`, the layer fields above, `frame?` | Changes how a layer is drawn, or the frame of a 4D layer |
233
+ | `remove_mesh_layer` | `mesh?`, `layer?` | Takes a layer off a mesh |
234
+
235
+ ### Signals and the graph
236
+
237
+ | Tool | Input | What it does |
238
+ |---|---|---|
239
+ | `load_signal` | `url`, `name?`, `replace?`, `as_signal?`, `display?`, `attach_to?`, `annotations?` | Loads a physiological trace or a spectroscopy voxel for the graph, drawn as `display` says (`mode`, `ppm_range`, `ppm_ref`, `use_hz`, `apodize_hz`, `phase0`, `phase1_ms`, `columns`, `average`, `legend`), attached to a volume, with notes on the graph. A page that starts with the graph hidden has it shown, so the signal is seen |
240
+ | `list_signals` | | The signals shown and the graph's range |
241
+ | `set_signal` | `signal?`, `display?`, `attach_to?`, `annotations?` | Changes how a signal is drawn, what it is attached to, or its notes |
242
+ | `remove_signal` | `signal?`, `all?` | Unloads one signal, or every signal |
243
+ | `set_graph` | `cursor?`, `step?`, `zoom?`, `pan?`, `range?`, `reset?` | Moves the graph cursor to a fraction or steps it, zooms and pans the graph, windows its range or clears the window, or resets the view. When the graph is hidden, the report says so (`graphHidden`) and names the option that shows it, as `list_signals` does too |
244
+
245
+ ### The drawing, annotations and measurements
246
+
247
+ | Tool | Input | What it does |
248
+ |---|---|---|
249
+ | `edit_drawing` | `action`, `url?`, `slice?`, `slice_index?` | Works on the voxel drawing over the base volume: `create` an empty one, `load` one from an address, `undo` the last stroke, `close` it, or trace a slice of it as `svg`. Strokes are made by hand on the page; the pen settings are in `set_options` |
250
+ | `list_annotations` | `polygons?`, `json?`, `svg?`, `slice?`, `slice_position?` | The vector annotations drawn on the slices, with their polygons, as JSON, or traced as SVG when asked, and the `style` an annotation drawn by hand takes |
251
+ | `edit_annotations` | `action`, `id?`, `text?`, `annotation?`, `json?`, `style?` | `add` a NiiVue VectorAnnotation (`id`; `sliceType` 0 axial, 1 coronal or 2 sagittal; `slicePosition`, where the slice is in mm along its own axis; `polygons`, each at least three `[x, y]` points in mm on the slice plane or `{outer, holes}`; and, if wanted, `label`, `group`, `text` and `style` with `fillColor`, `strokeColor` and `strokeWidth`, red otherwise), `remove` or `select` one by id, `set_text` on one, `clear` them, `undo`, `redo`, `load` a set from JSON, or `set_style` the style an annotation drawn by hand takes (`fillColor`, `strokeColor`, `strokeWidth`; a field left out keeps its value) |
252
+ | `list_measurements` | | The distance measurements drawn on the slices, each with its ends in millimetres and its length |
253
+ | `edit_measurements` | `action`, `start_mm?`, `end_mm?`, `slice?`, `slice_index?`, `slice_position?`, `index?` | `add` a distance between two points, `remove` one by index, or `clear` them all, the angles (`clear_angles`) or the distances (`clear_distances`) |
254
+ | `set_boxes` | `focus?`, `boxes?` | Outlines boxes on the 3D render (not the slices), each `{min, max, color?, thickness?}` with its corners in millimetres, a colour `[r, g, b]` or `[r, g, b, a]` 0 to 1 (a yellow otherwise) and its edges in pixels (2 otherwise): one `focus` box, a set of up to 1000 `boxes`, or both; `null` takes either down. NiiVue's `focusBox` and `lodBoxes`; not saved with the document |
255
+
256
+ ### Controls
257
+
258
+ | Tool | Input | What it does |
259
+ |---|---|---|
260
+ | `add_control` | `id`, `kind`, `row?`, `col?`, `x?`, `y?`, `label?`, `width?`, `value?`, `min?`, `max?`, `step?`, `options?`, `placeholder?`, `max_length?`, `rows?`, `accept?`, `multiple?`, `alpha?`, `palette?`, `message?`, `open?`, `enabled?`, `bind?` | Puts a control on the canvas: a `button`, `toggle`, `slider`, `menu`, `select`, `segmented` row, `number` or `text` field, `textarea`, `dialog`, `color` control or `file` picker, with the fields its kind takes. It goes in the grid, at `row` and `col` from 0 at the top left (`col` left out is 0, `row` left out is the one under the rest of that column); each column is as wide as its widest widget and each row as tall as its tallest, so controls in the grid never overlap; a cell holds one control, and a control asked into a taken cell is refused before it writes anything. `x` and `y` together (canvas pixels) place it at an exact spot instead, never with a `row` or `col`; a dialog takes no cell and is centred unless given `x` and `y`. The reply carries the `box` it was drawn in, in canvas pixels, when the surface draws; `bind` names what it drives, and an empty `bind` unbinds; a `value` given with it is written to the target first and waited for: a write NiiVue fails, at once or later, is the reply's error, and no control is added, though the target may keep what NiiVue set before it failed. A dialog opens when added unless `open` is false, and closes when the person picks one of its buttons; `set_control` with `open: true` shows it again. A value left out takes the kind's start: off, the low end of the range, empty, the first option, white |
261
+ | `list_controls` | | The controls there, each with its kind, grid cell or position, drawn `box`, value, options and binding; their ids also ride in every reply's state |
262
+ | `set_control` | `id`, and any of the fields above | Changes what is given on a control: a `row` or `col` moves it into the grid and `x` and `y` out of it; its value is checked and clamped as it was when added, and a range that moves takes the value with it. A value set on a bound control is written to its target and waited for: a write NiiVue fails is the reply's error, none of the change is made, and the control shows what the target holds |
263
+ | `remove_control` | `id?`, `all?` | Takes one control away, or all of them |
264
+
265
+ ### The data palette
266
+
267
+ A page that sets `host.data = dataPalette(catalog)` offers data by id. An entry is a load held for later: one of the loading tools (`load_volume`, `add_overlay`, `load_mesh`, `add_mesh_layer`, `load_signal`, `load_document`) with the arguments it takes, so loading an entry does exactly what that call would. The page seeds the palette with its own sample data (`source: 'page'`), which stays; an agent adds more (`source: 'agent'`). A button or menu item bound to `data.<id>` loads the entry when pressed. The ids ride in every reply's state, and `capabilities` lists the entries as `data`.
268
+
269
+ ```ts
270
+ host.data = dataPalette([
271
+ { id: 'mni152', label: 'MNI152', tool: 'load_volume', args: { url: '/volumes/mni152.nii.gz', mni: true } },
272
+ { id: 'aal', label: 'AAL atlas', tool: 'add_overlay', args: { url: '/volumes/aal.nii.gz', labels: '/volumes/aal.json' } },
273
+ ])
274
+ host.controls = bindControls(surface, { view: nv, actions, data: host.data })
275
+ ```
276
+
277
+ | Tool | Input | What it does |
278
+ |---|---|---|
279
+ | `list_data` | | The entries, each with its `id`, `label`, `description`, `tool`, `args` and `source` |
280
+ | `add_data` | `id`, `tool?`, `args?`, `label?`, `description?`, `from?` | Adds an entry: a call to one of the loading tools with its arguments (`url` at least), held for later; nothing loads now. `from` starts from an entry already there, such as a sample with another colormap: `args` are laid over its own while `tool` stays the same; a `tool` naming another loader starts its arguments and description afresh. A `tab` among the arguments is dropped: an entry loads where it is pressed |
281
+ | `remove_data` | `id` | Takes an agent's entry away; the page's own stay |
282
+ | `load_data` | `id` | Loads the entry and reports what its loading tool reports |
283
+
284
+ ### The canvas, the slide plane, chunks and files
285
+
286
+ | Tool | Input | What it does |
287
+ |---|---|---|
288
+ | `set_viewport` | `pan?`, `zoom?`, `bounds?`, `reset?` | Pans and zooms the canvas in 2D, bounds where NiiVue draws to a box of fractions (or `null` to clear it), or resets |
289
+ | `map_point` | `canvas?`, `mm?`, `vox?` | What is under a canvas pixel (the tile, the slice, the point in millimetres), or where a point in millimetres or a voxel lands on the canvas |
290
+ | `set_slide` | `level?`, `clear_plane?`, `drawing?`, `max_raster?` | For a whole-slide image: the level the slide plane shows (or `null` for automatic), clearing the plane, and the slide drawing (`create`, `clear`, `undo`, `end`) |
291
+ | `chunk_stats` | `rebake?`, `reset_timing?` | For a chunked (OME-Zarr, IIIF) volume: the streaming, timing and level-of-detail statistics; rebakes the chunked overlays or resets the timing when asked |
292
+ | `save` | `what`, `filename?`, `volume?`, `drawing?`, `mesh?`, `quality?`, `format?`, `settings_never_saved?`, `settings_always_saved?` | Has the browser download the scene as a NiiVue document, a volume (with the drawing when asked), a mesh, the canvas as a picture, or the drawing |
293
+ | `load_document` | `url`, `fill?` | Loads a NiiVue document from an address, replacing the scene; `fill` says whether settings the document leaves out take their defaults or stay as they are |
294
+
295
+ What is left out is what an MCP tool cannot carry: the members of NiiVue that take a DOM node, a canvas, a `File`, a callback or another object rather than a value. Attaching to a canvas, resizing, the draw and refresh methods, the event listeners, loaders and custom overlay renderers are registered by the page (so are volume transforms, but once registered they take a name and options, so `transform_volume` runs them and `capabilities` lists them); pointer-driven picking (`slideDrawAt`, `slidePlanePick`, `pickMeasurement`, `pickExplodedBlock`) belongs to the pointer; `loadChunkedVolume`, `setSlidePlane`, `swapVolumeChunkPlan` and `addMrsiSignal` take live source objects a page builds; `setFont` takes decoded font data, so `set_font` takes the addresses instead; `broadcastTo` links one NiiVue to another on the page, which the page does; `clientToCanvas`, `getScreenTiles` and `mmToRenderCanvas` read the browser window's pixel layout, which an agent sees through `map_point` and `screenshot` instead; members marked `@internal` (such as `updateTilesFromInstances`) are NiiVue's own plumbing behind a public call the tools already make. Every one of those is a page concern, not a scene state, and the page an agent drives is free to expose any of them through its own extension tools.
296
+
297
+ ### Planes and cameras
298
+
299
+ A plane is named for the side it takes off: `left`, `right`, `posterior`, `anterior`, `inferior`, `superior`, with `sagittal`, `coronal` and `axial` as aliases for the first of each pair. NiiVue keeps a clip plane as `[depth, azimuth, elevation]`, with the shader keeping the side the plane's normal points to; the camera that sees the exposed face square on looks along the normal, which works out to the plane's own elevation and its azimuth turned half a turn. `cameraForPlane` does that sum and `depthThrough` finds the depth that puts the plane through a point, both checked for all six sides in `planes.test.ts`.
300
+
301
+ ### Regions
302
+
303
+ `findRegion` matches a query against each region's label and spoken name: exactly first, then by containment with case and underscores ignored. One hit is a match; several hits are an ambiguity, reported with the candidate names, unless one of them is exact. `SPOKEN_NAMES` is the AAL table in anatomical English (`Frontal_Inf_Tri_L` is `left inferior frontal gyrus, triangular part`), with a name generated from the label's parts for any label not in it.
304
+
305
+ ## Tabs
306
+
307
+ Every page that connects says hello with an id, a title, its address and its state, and the server welcomes it with the id it will list the tab under. The id is kept in the tab's `sessionStorage`, which a browser scopes to one tab and keeps across reloads, so a reload arrives as the same id on a new socket and a second tab arrives as a new id. A duplicated tab is the exception: the browser copies its `sessionStorage`, so the copy arrives with the original's id while the original is still connected. The bridge keeps the original as it is and welcomes the copy with a spare id, the original's with `-2` on the end, which the copy keeps for its own reloads; both are then listed, and a call with neither chosen asks, as with any two tabs.
308
+
309
+ A page opened with `?tab=<id>` in its address takes that id instead, and keeps it in `sessionStorage` too, so its reloads and a later address without the parameter still arrive as it. A duplicate of such a tab is renamed by the server as any duplicate is, and keeps the spare id across its own reloads although its address still names the original. That is how an agent names a tab before it exists: `new_tab` makes up a UUID and returns it with the address to open, the person opens it, and every later call in the conversation passes the id as `tab` and reaches that tab whatever else is connected or chosen. An agent may make up any id itself and put it in the address the same way; `new_tab` only spares it the guessing and knows where the page is.
310
+
311
+ Which tab answers a call:
312
+
313
+ - **The call names a `tab`**: that one, if it is connected, else the call fails and lists the tabs. The choice made with `use_tab` is left as it was.
314
+ - **One tab connected**: that one.
315
+ - **A tab chosen with `use_tab`**: that one, for as long as it is connected.
316
+ - **Several connected and none chosen**: the one that answered last, if it is still connected; otherwise the call fails and lists the tabs, so the agent can call `use_tab` rather than drive a tab nobody is looking at.
317
+ - **The tab reloads mid-call**: the call fails saying so. On the next call the bridge waits a few seconds for the same id to come back, then answers from it and puts a note at the top of the reply saying the tab reloaded and what its state was before and is now, so the agent knows the crosshair, plane and anything the app adds started over. The note is said once. A page that comes back on a new socket without reloading, after the server restarted or the socket dropped, says so in its hello and gets no note, since its scene is as it was.
318
+ - **No tab connected**: the call fails with `NO_APP` plus the server's `noTabHint`, so the agent can tell the person what to open.
319
+
320
+ Every answer carries `tab: {id, title}` in `where_am_i`, and `list_tabs` marks the one that is answering.
321
+
322
+ ## Adding tools
323
+
324
+ Extra handlers on the page are more entries in the object passed to `AgentClient`; they receive the tool's arguments and return the JSON the agent sees, or throw to fail the call. Add their state to what the `state` callback returns and the reload note will cover it.
325
+
326
+ On the server, an `Extension` is a name and a `register(server, context)` that calls `server.registerTool` for each tool, answering through `context.answer(method, params, options)`, which forwards to the bound tab, or to the one `options.tab` names, and wraps the reply. Spread `TAB_ARG` into the tool's schema so an agent can name the tab for it as it does for the core tools:
327
+
328
+ ```ts
329
+ import { z } from 'zod'
330
+ import { type Extension, TAB_ARG } from '@niivue/nv-mcp/server'
331
+
332
+ const light: Extension = {
333
+ name: 'light',
334
+ register(server, context) {
335
+ server.registerTool(
336
+ 'set_light',
337
+ { description: 'Turns the room light on or off.', inputSchema: { ...TAB_ARG, on: z.boolean() } },
338
+ ({ tab, ...params }) => context.answer('set_light', params, { tab, lead: () => 'Done.' }),
339
+ )
340
+ },
341
+ }
342
+
343
+ startServer({ name: 'my-niivue-app', extensions: [light] })
344
+ ```
345
+
346
+ `src/server/http.test.ts` drives a server with exactly that extension against `src/testing/fake-page.ts`, a page with no browser at all.
347
+
348
+ ## Development
349
+
350
+ ```bash
351
+ bunx nx build nv-mcp # Build
352
+ bunx nx test nv-mcp # Run the tests (bun test)
353
+ bunx nx typecheck nv-mcp # Type-check
354
+ bunx nx lint nv-mcp # Biome
355
+ bunx nx dev demo-mcp # The demo page and its server together
356
+ ```
357
+
358
+ ## Part of the [NiiVue](https://github.com/niivue) ecosystem
@@ -0,0 +1,3 @@
1
+ import { Handlers, NiiVueHost } from './view';
2
+ /** The handler for the animate tool. */
3
+ export declare function animateHandlers(host: NiiVueHost): Handlers;
@@ -0,0 +1,69 @@
1
+ import { ControlValue } from '../controls';
2
+ import { ControlEvent, ControlSurface, HostedControls } from './controls';
3
+ import { DataPalette } from './data';
4
+ import { View } from './view';
5
+ /** Something the page can do when a control is used, by name. */
6
+ export interface PageAction {
7
+ /** What it does, in words, for the agent choosing one. */
8
+ description: string;
9
+ /**
10
+ * Does it. Gets the event: a button's press, a menu item chosen, a
11
+ * value committed, files picked, the button that closed a dialog.
12
+ */
13
+ run(event: ControlEvent): unknown;
14
+ }
15
+ export interface BindOptions {
16
+ view: View;
17
+ /** The page's own actions, which `action.<name>` binds a control to. */
18
+ actions?: Readonly<Record<string, PageAction>>;
19
+ /** The page's data palette, which `data.<id>` loads from. */
20
+ data?: DataPalette;
21
+ /** Hears an action or a write that failed after the person used a control. */
22
+ onError?(error: Error, event: ControlEvent): void;
23
+ /**
24
+ * How long an agent's write may take before its call fails, in ms
25
+ * (WRITE_WAIT_MS). Keep it under the server's call timeout, or the agent
26
+ * hears only that the page did not answer.
27
+ */
28
+ writeWait?: number;
29
+ }
30
+ /**
31
+ * How long an agent's control write may take before its call fails, in ms:
32
+ * well inside the server's call timeout, so the agent hears why.
33
+ */
34
+ export declare const WRITE_WAIT_MS: number;
35
+ /**
36
+ * A surface whose controls drive what they are bound to. `add` and
37
+ * `update` given a value return a promise when NiiVue finishes setting
38
+ * what the control drives later, and reject when it fails.
39
+ */
40
+ export interface BoundControls extends HostedControls {
41
+ /** Reads every bound value and shows the ones that changed. */
42
+ sync(): void;
43
+ /** Stops listening to the surface it wraps. */
44
+ dispose(): void;
45
+ }
46
+ type Shape = 'boolean' | 'number' | 'string' | 'color';
47
+ /** What a `bind` resolved to. */
48
+ interface Target {
49
+ /** The kind of value it takes, or `action` for one that runs on use. */
50
+ shape: Shape | 'action';
51
+ /** The strings it takes, when it takes only some. */
52
+ choices?: readonly string[];
53
+ min?: number;
54
+ max?: number;
55
+ step?: number;
56
+ /** Its value now; undefined when it cannot be read just now. */
57
+ read?(): ControlValue | undefined;
58
+ /** Sets it, throwing in words before changing anything when it cannot; NiiVue may finish later. */
59
+ write?(value: ControlValue): unknown;
60
+ action?: PageAction;
61
+ }
62
+ /** What `bind` names on this page, or a throw in words saying what it may name. */
63
+ export declare function resolveBinding(bind: string, view: View, actions?: Readonly<Record<string, PageAction>>): Target;
64
+ /**
65
+ * `surface` with its controls' `bind` acted on, over `options.view` and
66
+ * the page's `options.actions`. See the module comment for what a bind names.
67
+ */
68
+ export declare function bindControls(surface: ControlSurface, options: BindOptions): BoundControls;
69
+ export {};
@@ -0,0 +1,118 @@
1
+ import { AgentRequest, AgentResponse, TabState } from '../protocol';
2
+ import { Handlers } from './view';
3
+ /** Where the server itself listens for pages. */
4
+ export declare const AGENT_URL = "ws://127.0.0.1:4242/app";
5
+ /** The path a dev server proxies to the server, on the page's own origin. */
6
+ export declare const AGENT_PATH = "/agent";
7
+ /** The key the tab's id is kept under in `sessionStorage`. */
8
+ export declare const TAB_ID_KEY = "nv-mcp.tab";
9
+ /**
10
+ * The addresses to try, in turn: the page's own origin first, since a browser
11
+ * that allows a page one origin only (Claude's built-in pane) can reach
12
+ * nothing else, then the server directly, for a build served without the
13
+ * proxy in front of it. A page opened from a file has no origin to try.
14
+ */
15
+ export declare function agentUrls(loc?: Location): string[];
16
+ /** How long to wait before the first retry, and the longest wait after that. */
17
+ export declare const RETRY_MS: {
18
+ readonly first: 1000;
19
+ readonly longest: 30000;
20
+ };
21
+ /** How long an address gets to open before it is closed and the next one tried. */
22
+ export declare const CONNECT_MS = 5000;
23
+ /** Where a tab's id is kept between its reloads. */
24
+ export type IdStorage = Pick<Storage, 'getItem' | 'setItem'>;
25
+ /**
26
+ * This tab's id. A `?tab=<id>` in the page's address names it: an agent's
27
+ * `new_tab` hands such an address out, so the id is agreed before the page
28
+ * opens, and a reload keeps the address. Otherwise it is kept in
29
+ * `sessionStorage`, which a browser scopes to one tab and keeps across its
30
+ * reloads but does not copy to a new tab, other than by duplicating it;
31
+ * the server catches that case and hands the copy an id of its own. A
32
+ * duplicate of a `?tab` page keeps that spare id too: the address that
33
+ * claimed the storage is remembered, and while the address still names
34
+ * it, the stored id wins over it, so the copy's reloads do not take the
35
+ * original's id back. Made up fresh when storage is not available.
36
+ * Whichever way it came, it is written to storage, so the page keeps it
37
+ * should it navigate to an address without the parameter.
38
+ */
39
+ export declare function tabId(storage?: IdStorage | null, search?: string): string;
40
+ export interface ClientOptions {
41
+ /** The addresses to try in turn; `agentUrls()` otherwise. */
42
+ urls?: readonly string[];
43
+ /** The id to say hello with, used as given; `tabId()` otherwise. */
44
+ id?: string;
45
+ /** Where `tabId()` keeps the id, and a spare one the server hands out; `sessionStorage` otherwise. */
46
+ storage?: IdStorage | null;
47
+ /** The query string `tabId()` reads `?tab=` from; `location.search` otherwise. */
48
+ search?: string;
49
+ /** What the tab is called now; `document.title` otherwise. */
50
+ title?: () => string;
51
+ /** Where the page is; `location.href` otherwise. */
52
+ url?: () => string;
53
+ /** Where the scene stands, for the hello and each answer's envelope. */
54
+ state: () => TabState;
55
+ /** Told when the server is reached and when it is lost, for a status line. */
56
+ onStatus?: (connected: boolean) => void;
57
+ /** A `WebSocket` to use instead of the page's; for tests. */
58
+ WebSocket?: typeof WebSocket;
59
+ }
60
+ /**
61
+ * Puts one request to the handlers and shapes the answer. Anything thrown
62
+ * becomes the error text the agent reads, so handlers throw in words.
63
+ */
64
+ export declare function serve(handlers: Handlers, request: AgentRequest): Promise<AgentResponse>;
65
+ export declare class AgentClient {
66
+ private readonly handlers;
67
+ private readonly options;
68
+ private ownId;
69
+ private readonly storage;
70
+ private readonly urls;
71
+ private readonly Socket;
72
+ private socket;
73
+ private retry;
74
+ private wait;
75
+ private attached;
76
+ /** Which of the addresses the next attempt goes to. */
77
+ private attempt;
78
+ /** Whether the settled retry has been reported since the last connection. */
79
+ private reported;
80
+ /** Whether this page has reached the server before, so a new socket is a reconnect. */
81
+ private connectedBefore;
82
+ /** The server that welcomed the page on the socket it has now, once it has. */
83
+ private server;
84
+ /** Answers to calls whose socket went before they were done, with the server that sent them. */
85
+ private outbox;
86
+ /** Requests taken and not yet done, in order, with the server that sent each. */
87
+ private taken;
88
+ /** Settles once the last request taken is done; the next one starts then. */
89
+ private line;
90
+ constructor(handlers: Handlers, options: ClientOptions);
91
+ /** The id the server lists this tab under. */
92
+ get id(): string;
93
+ /** Whether the server is reached now. */
94
+ get connected(): boolean;
95
+ attach(): void;
96
+ detach(): void;
97
+ private title;
98
+ private url;
99
+ private hello;
100
+ /** Every call this page has yet to answer, running, waiting, or held. */
101
+ private owed;
102
+ /** Takes the id the server welcomed this tab with, and keeps it where a reload will find it. */
103
+ private welcomed;
104
+ private connect;
105
+ /**
106
+ * Sends the answers held for the server now welcoming the page. Any held
107
+ * for another run of the server are dropped: nothing there waits for them.
108
+ */
109
+ private flush;
110
+ private scheduleRetry;
111
+ /**
112
+ * A welcome is taken at once. A request waits for the one taken before it
113
+ * to be done, from this socket or an earlier one, then is answered on
114
+ * this socket if it is still the page's, or held for the next.
115
+ */
116
+ private receive;
117
+ private handle;
118
+ }