From 348da9d33480466b5eb162ca3ae39e41882c9b2f Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Mon, 6 Jul 2026 08:21:59 +0200 Subject: [PATCH] feat(mcp): add read-only getState tool (portable read-side hedge) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the read side started by the resources projection. MCP resources are the correct read side but the least-supported MCP primitive (some hosts — e.g. Cursor — are tools-only); a getState TOOL is the portable hedge that works on any tools-capable host, including a direct Anthropic/Vercel tool projection (research-11 §3.2, "ship both"). Directly serves reelee-web, which consumes buildToolsList to feed Anthropic tools (not MCP). - Pure (resources.ts): buildGetStateTool(views, opts) → a wire-safe (app_getState default) readOnlyHint tool descriptor whose `view` param enumerates the listed view ids; callGetState(views, args) reads the view as errors-as-data (invalid view → isError; unknown/internal → null). - Server: createMcpServer(registry, { views, getStateTool: true }) merges the tool into tools/list and routes it in tools/call. Default off; requires views. Fully backward-compatible. - McpToolDescriptor gained an optional `annotations` field (readOnlyHint etc.); the getState tool sets it. Command-tool projection unchanged. - +5 tests (28 in the package); typecheck + build + workspace green. Docs: README getState section; acture-mcp + acture-ai-assistant skills note the shipped tool; roadmap v1.16 entry. minor changeset (1.2.0 → 1.3.0). Claude-Session: https://claude.ai/code/session_016teVFousRsWNhiiPNpG1p9 --- .changeset/mcp-getstate.md | 20 +++++ .claude/skills/acture-ai-assistant/SKILL.md | 2 +- .claude/skills/acture-mcp/SKILL.md | 2 +- docs/roadmap.md | 13 ++- packages/mcp/README.md | 18 +++- packages/mcp/src/index.ts | 12 +++ packages/mcp/src/resources.test.ts | 52 ++++++++++++ packages/mcp/src/resources.ts | 93 +++++++++++++++++++++ packages/mcp/src/server.ts | 32 ++++++- packages/mcp/src/tools.ts | 17 ++++ 10 files changed, 255 insertions(+), 6 deletions(-) create mode 100644 .changeset/mcp-getstate.md diff --git a/.changeset/mcp-getstate.md b/.changeset/mcp-getstate.md new file mode 100644 index 0000000..89cbc52 --- /dev/null +++ b/.changeset/mcp-getstate.md @@ -0,0 +1,20 @@ +--- +"acture-mcp-server": minor +--- + +Add a read-only **`getState` tool** — the portable read-side hedge for tools-only +hosts (research-11 §3.2). MCP resources are the correct read side but the +least-supported MCP primitive; a single `getState` tool works on **any** +tools-capable host (tools-only MCP hosts like Cursor, or a direct Anthropic/Vercel +tool projection). + +- **Pure:** `buildGetStateTool(views, opts)` → a wire-safe (`app_getState` by + default), `readOnlyHint` tool descriptor whose `view` param enumerates the listed + view ids; `callGetState(views, args)` reads the requested view (errors-as-data — + invalid `view` → `isError`, unknown/internal view → `null`). Feed the descriptor + into a `tools/list` alongside `buildToolsList`, or straight into a non-MCP tool + array. +- **Server:** `createMcpServer(registry, { views, getStateTool: true })` merges the + tool into `tools/list` and routes it in `tools/call`. Default off; requires `views`. +- `McpToolDescriptor` gained an optional `annotations` field (`readOnlyHint` etc.); + the getState tool sets it. Additive; command-tool projection is unchanged. diff --git a/.claude/skills/acture-ai-assistant/SKILL.md b/.claude/skills/acture-ai-assistant/SKILL.md index c3c096c..0c83c32 100644 --- a/.claude/skills/acture-ai-assistant/SKILL.md +++ b/.claude/skills/acture-ai-assistant/SKILL.md @@ -25,7 +25,7 @@ The seam: acture projects the registry (write) and state model (read) into whate No surveyed framework ships a first-class typed channel for exposing an app's *current state* to the model (research-11 §3). acture can, because its state adapter already exposes `getState()` / `subscribe()` and (via `PatchCapableAdapter`) emits RFC-6902-*compatible* patches (the Immer-subset shape — a trivial `path`→JSON-Pointer transform feeds AG-UI's canonical `STATE_DELTA`). Define a **view** — the read-side dual of a command: a named, described, tier-tagged, sensitivity-scoped selector over state — and project one `ViewRegistry` to three read channels, symmetric to how the schema bridge projects a command: - **MCP resources** (`app://state/`, with `resources/subscribe` liveness) — the semantically correct, *application-driven* representation. **Shipped** in `acture-mcp-server`: `createMcpServer(registry, { views })` + the pure `buildResourcesList` / `readResource` (see the `acture-mcp` skill). Or hand-write it from `docs/hand-written-view-registry.md`. -- **A universal read-only `getState` tool** (`readOnlyHint: true`) — the portable hedge, because resources are the least-supported MCP primitive (Cursor and many hosts are tools-only). +- **A universal read-only `getState` tool** (`readOnlyHint: true`) — the portable hedge, because resources are the least-supported MCP primitive (Cursor and many hosts are tools-only). **Shipped** in `acture-mcp-server`: `createMcpServer(registry, { views, getStateTool: true })`, or the pure `buildGetStateTool` / `callGetState` for a direct Anthropic/Vercel tool array. - **An AG-UI `STATE_SNAPSHOT`/`STATE_DELTA` bridge** — for an in-app assistant; maps `PatchCapableAdapter` patches into `STATE_DELTA` (RFC-6902-compatible; a trivial `path`→pointer transform, not a raw pass-through). **Ship both resources and the tool**, from one registry (spec says resources, host reality says tools). Reproducible core: `docs/hand-written-view-registry.md`. Leakage control is `tier` + `sensitivity` (`internal`/`secret` never projected); token budget is *narrow selectors* + just-in-time pull. diff --git a/.claude/skills/acture-mcp/SKILL.md b/.claude/skills/acture-mcp/SKILL.md index d4dd586..961045e 100644 --- a/.claude/skills/acture-mcp/SKILL.md +++ b/.claude/skills/acture-mcp/SKILL.md @@ -39,7 +39,7 @@ Tools are the **write** side. An assistant that *operates* the app also needs th - **Pure layer (`resources.ts`, SDK-free):** `buildResourcesList(views, opts)` → resource descriptors, `readResource(views, uri)` → contents. Same two-layer discipline as `tools.ts`; any transport consumes it. - **Server glue:** `createMcpServer(registry, { views, ... })` — the optional `views` opts into `resources/list` + `resources/read` + `resources/subscribe`, advertises the `resources` capability, and fires `notifications/resources/updated` from the source's `onStateChanged`. Omit `views` → tools-only, unchanged. - **Tier-filtered like tools** — `internal` views never projected; the same `tiers` option applies. Enforcement is the `ViewSource`'s job (its `read` returns `undefined` for internal/secret), exactly as core's `dispatch` enforces the write side. -- **Ship both resources AND a `getState` tool.** Resources are the correct, app-driven representation but the *least-supported* MCP primitive (some hosts are tools-only). For those, also expose a read-only `getState` tool (`readOnlyHint: true`). See `acture-ai-assistant` and research-11 §3.2. +- **Ship both resources AND a `getState` tool** (both now shipped). Resources are the correct, app-driven representation but the *least-supported* MCP primitive (some hosts are tools-only). For those, the read-only **`getState` tool** is the portable hedge: `createMcpServer(registry, { views, getStateTool: true })` merges it into `tools/list`, or use the pure `buildGetStateTool(views)` / `callGetState(views, args)` for a non-MCP (direct Anthropic/Vercel) tool array. It carries `readOnlyHint: true` (via the new `McpToolDescriptor.annotations`). See `acture-ai-assistant` and research-11 §3.2. The full "operate my app" story (read side + confirmation gate + macro capture) is the `acture-ai-assistant` skill; this section is just the MCP-resources projection. diff --git a/docs/roadmap.md b/docs/roadmap.md index bf81938..eb03402 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -4,7 +4,7 @@ The live forward-planning surface. `docs/v1_plan.md` and `docs/implementation_pl **How work proceeds:** phases are over. Work is small, tracked increments. Each picks one or two items from "Next" or "Deferred", ships them, updates this file, and replaces `docs/next_session.md` with the following handoff. -Last updated: **2026-07-06** (v1.15 — acture-mcp read side: MCP resources projection, the first v1.14-deferred accelerator pulled forward). +Last updated: **2026-07-06** (v1.16 — acture-mcp read side completed with the portable `getState` tool; follows v1.15's resources projection). --- @@ -136,6 +136,17 @@ The first of the v1.14-deferred package accelerators, pulled forward: the AI **r --- +### v1.16 — acture-mcp read side: the `getState` tool (portable hedge) — complete (this increment) + +Completes the read side started in v1.15. MCP **resources** are the correct read side but the *least-supported* MCP primitive (some hosts — e.g. Cursor — are tools-only); a **`getState` tool** is the portable hedge that works on any tools-capable host, including a direct Anthropic/Vercel tool projection (research-11 §3.2, "ship both"). This directly serves the named consumer `reelee-web`, which consumes `buildToolsList` to feed **Anthropic** tools (not MCP) — so a pure `getState` tool descriptor slots straight into its flow. + +- **Pure (`resources.ts`):** `buildGetStateTool(views, opts)` → a wire-safe (`app_getState` default, `^[a-zA-Z0-9_-]{1,64}$`), `readOnlyHint` tool descriptor whose `view` param enumerates the listed view ids; `callGetState(views, args)` reads the requested view as errors-as-data (invalid `view` → `isError`; unknown/internal view → `null`). Feed the descriptor into a `tools/list` alongside `buildToolsList`, or straight into a non-MCP tool array. +- **Server:** `createMcpServer(registry, { views, getStateTool: true })` merges the tool into `tools/list` and routes it in `tools/call`. Default off; requires `views`. Fully backward-compatible. +- **`McpToolDescriptor` gained an optional `annotations` field** (`readOnlyHint`/`destructiveHint`/`idempotentHint`/`openWorldHint`) — the getState tool sets it. Additive; the command-tool projection is unchanged (deriving annotations from a command side-effect class stays deferred with the confirmation-gate work). +5 tests (28 total in the package); typecheck + build + workspace green. `minor` changeset (`acture-mcp-server` 1.2.0 → 1.3.0). +- **Still deferred:** the keymap-customization helper (the second v1.14 accelerator) and the `sideEffect`/`requiresConfirmation` closed-surface question. + +--- + ## Next **The autonomous v1.12 + v1.13 chain is complete.** The remaining post-v1 items need user direction; the rewritten `docs/next_session.md` surfaces them. The candidates are: **`acture-state-jotai`** (atom-tree ↔ flat-state bridge is non-trivial per research-3; the adapter may not implement `PatchCapableAdapter` cleanly), **`acture-state-valtio`** (proxy-to-patch translation is non-trivial). (**`acture-sandbox`** is no longer a candidate — its isolation-only seam shipped in the extension-system increment; see below.) Pull-forward decisions are the user's; surface options with honest trade-offs when scheduled. diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 04a9927..beed9d4 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -66,7 +66,23 @@ With `views`, the server advertises the `resources` capability and registers `re The pure functions are exported too, for non-stdio transports: `buildResourcesList(views, opts)` → resource descriptors, `readResource(views, uri)` → contents. -> **Ship both resources and a `getState` tool.** MCP resources are the *correct*, app-driven representation, but they are the least-supported MCP primitive (some hosts are tools-only). For those hosts, also expose a read-only `getState` tool. See the `acture-ai-assistant` skill and research-11 §3. +### The `getState` tool — portable read-side hedge + +MCP resources are the *correct*, app-driven read side, but they are the **least-supported** MCP primitive — some hosts (e.g. Cursor) are tools-only. So **ship both**: also expose a read-only `getState` **tool**, which works on any tools-capable host (including a direct Anthropic/Vercel tool projection). + +```ts +const server = createMcpServer(registry, { + name: 'graph-editor', + version: '0.1.0', + views, + getStateTool: true, // ← also expose a read-only getState tool + // getStateTool: { name: 'read_state', tiers: ['stable'] }, // or customize +}); +``` + +The model calls `getState({ view })` to pull one view's current value. The tool carries `readOnlyHint: true` so well-behaved hosts auto-approve it without friction; its `view` argument enumerates the listed view ids. + +The pure functions are exported for non-MCP hosts (a direct Anthropic tool array, say): `buildGetStateTool(views, opts)` → a wire-safe (`app_getState` by default) tool descriptor, `callGetState(views, args)` → errors-as-data result. See the `acture-ai-assistant` skill and research-11 §3.2. ## Tier semantics diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 1137cf2..8c35f50 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -25,6 +25,12 @@ * them via `createMcpServer(registry, { views })`, which adds * `resources/list` + `resources/read` + `resources/subscribe`. Omit * `views` for a tools-only server (unchanged). + * + * - `buildGetStateTool(views, opts)` / `callGetState(views, args)` are the + * portable read-side hedge — a single read-only `getState` tool for + * tools-only hosts that don't support MCP resources (Cursor, or a direct + * Anthropic/Vercel projection). Enable on the server via + * `createMcpServer(registry, { views, getStateTool: true })`. */ export { @@ -35,6 +41,7 @@ export { export type { BuildToolsListOptions, McpToolDescriptor, + McpToolAnnotations, CallToolResponse, } from './tools.js'; @@ -48,7 +55,10 @@ export { buildResourcesList, readResource, viewIdToUri, + buildGetStateTool, + callGetState, DEFAULT_RESOURCE_PREFIX, + DEFAULT_GET_STATE_TOOL_NAME, } from './resources.js'; export type { ViewSource, @@ -56,4 +66,6 @@ export type { McpResourceDescriptor, BuildResourcesListOptions, ResourceContents, + GetStateToolOptions, + GetStateResponse, } from './resources.js'; diff --git a/packages/mcp/src/resources.test.ts b/packages/mcp/src/resources.test.ts index b81194f..d10e700 100644 --- a/packages/mcp/src/resources.test.ts +++ b/packages/mcp/src/resources.test.ts @@ -4,7 +4,10 @@ import { buildResourcesList, readResource, viewIdToUri, + buildGetStateTool, + callGetState, DEFAULT_RESOURCE_PREFIX, + DEFAULT_GET_STATE_TOOL_NAME, type ResourceView, type ViewSource, } from './resources.js'; @@ -115,3 +118,52 @@ describe('viewIdToUri', () => { expect(DEFAULT_RESOURCE_PREFIX).toBe('app://state/'); }); }); + +describe('buildGetStateTool', () => { + it('builds a read-only tool advertising the stable view ids', () => { + const tool = buildGetStateTool(makeViews()); + expect(tool.name).toBe(DEFAULT_GET_STATE_TOOL_NAME); + expect(tool.name).toMatch(/^[a-zA-Z0-9_-]{1,64}$/); // wire-safe for Anthropic/OpenAI/MCP + expect(tool.annotations).toEqual({ + readOnlyHint: true, + openWorldHint: false, + idempotentHint: true, + }); + const schema = tool.inputSchema as { + properties: { view: { enum: string[] } }; + required: string[]; + }; + expect(schema.properties.view.enum).toEqual(['app.selection', 'app.mode']); // internal excluded + expect(schema.required).toEqual(['view']); + expect(tool.description).toContain('app.selection'); + }); + + it('honours a custom name and tier filter', () => { + const tool = buildGetStateTool(makeViews(), { + name: 'read_state', + tiers: ['stable', 'experimental'], + }); + expect(tool.name).toBe('read_state'); + const schema = tool.inputSchema as { properties: { view: { enum: string[] } } }; + expect(schema.properties.view.enum).toEqual(['app.selection', 'app.mode', 'app.beta']); + }); +}); + +describe('callGetState', () => { + it('reads a known view as JSON content', () => { + const res = callGetState(makeViews(), { view: 'app.selection' }); + expect(res.isError).toBeUndefined(); + expect(JSON.parse(res.content[0]!.text)).toEqual(['n1', 'n2']); + }); + + it('reads an unknown / internal view as null (no leak)', () => { + expect(callGetState(makeViews(), { view: 'app.nope' }).content[0]!.text).toBe('null'); + expect(callGetState(makeViews(), { view: 'app.debug' }).content[0]!.text).toBe('null'); + }); + + it('returns errors-as-data for a missing / non-string view', () => { + const res = callGetState(makeViews(), {}); + expect(res.isError).toBe(true); + expect(JSON.parse(res.content[0]!.text).code).toBe('invalid_params'); + }); +}); diff --git a/packages/mcp/src/resources.ts b/packages/mcp/src/resources.ts index 9c5e58a..f4a1d3b 100644 --- a/packages/mcp/src/resources.ts +++ b/packages/mcp/src/resources.ts @@ -14,6 +14,7 @@ */ import type { Tier } from 'acture'; +import type { McpToolDescriptor } from './tools.js'; /** A view descriptor as listed by a {@link ViewSource} — the read-side dual * of an MCP tool descriptor. The selector and state live in the app's @@ -117,3 +118,95 @@ export function readResource( export function viewIdToUri(id: string, uriPrefix = DEFAULT_RESOURCE_PREFIX): string { return `${uriPrefix}${id}`; } + +/* ─────────────────────── getState tool (the portable hedge) ──────────────── */ + +export interface GetStateToolOptions { + /** Tool name. Default `'app_getState'`. MUST be wire-safe + * (`^[a-zA-Z0-9_-]{1,64}$`) — it is exposed to Anthropic/OpenAI/MCP hosts + * verbatim (unlike command ids, which are sanitized) — and distinct from any + * command tool name. */ + name?: string; + /** Leading description text (the available-views list is appended). */ + description?: string; + /** Tier filter for the advertised view ids. Default `['stable']`. */ + tiers?: readonly Tier[] | 'all'; +} + +/** Default name for the getState tool. Wire-safe and app-namespaced. */ +export const DEFAULT_GET_STATE_TOOL_NAME = 'app_getState'; + +/** + * Build a single read-only `getState` tool descriptor — the **portable hedge** + * (research-11 §3.2). MCP resources are the correct read side, but the + * least-supported MCP primitive; tools are universal. This one tool lets a model + * pull any listed view on **any** tools-capable host (tools-only MCP hosts like + * Cursor, or a direct Anthropic/Vercel projection). `readOnlyHint: true` lets + * well-behaved hosts auto-approve it without friction. + * + * Pure — returns an {@link McpToolDescriptor}. Feed it into a `tools/list` + * alongside {@link buildToolsList}, or straight into a non-MCP tool array. Pair + * with {@link callGetState} for dispatch. + */ +export function buildGetStateTool( + views: ViewSource, + options: GetStateToolOptions = {}, +): McpToolDescriptor { + const name = options.name ?? DEFAULT_GET_STATE_TOOL_NAME; + const tiers = options.tiers ?? ['stable']; + const ids = views.list({ tiers }).map((v) => v.id); + const base = + options.description ?? + 'Read the current value of one app-state view. Call before acting to see current state.'; + return { + name, + description: `${base} Available views: ${ids.length > 0 ? ids.join(', ') : '(none)'}.`, + inputSchema: { + type: 'object', + properties: { + view: { + type: 'string', + enum: ids, + description: 'Which state view to read.', + }, + }, + required: ['view'], + additionalProperties: false, + }, + annotations: { readOnlyHint: true, openWorldHint: false, idempotentHint: true }, + }; +} + +/** The response shape shared with `callTool` — errors-as-data on the wire. */ +export interface GetStateResponse { + content: Array<{ type: 'text'; text: string }>; + isError?: boolean; +} + +/** + * Execute a getState call — read the requested view and return its JSON value + * as MCP tool-result content. Errors are **data** (never thrown): a missing / + * non-string `view` returns `isError: true`; an unknown / internal / secret + * view reads as `null` (the `ViewSource` enforces that, per {@link readResource}). + */ +export function callGetState(views: ViewSource, args: unknown): GetStateResponse { + const view = (args as { view?: unknown } | null | undefined)?.view; + if (typeof view !== 'string') { + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + code: 'invalid_params', + message: 'getState requires a string "view" argument.', + }), + }, + ], + isError: true, + }; + } + const value = views.read(view); + return { + content: [{ type: 'text', text: JSON.stringify(value ?? null, null, 2) }], + }; +} diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index e49bf4a..7a0b68b 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -21,8 +21,12 @@ import type { Context, Registry, Tier } from 'acture'; import { buildToolsList, callTool } from './tools.js'; import { buildResourcesList, + buildGetStateTool, + callGetState, readResource, + DEFAULT_GET_STATE_TOOL_NAME, type BuildResourcesListOptions, + type GetStateToolOptions, type ViewSource, } from './resources.js'; @@ -46,6 +50,11 @@ export interface CreateMcpServerOptions { views?: ViewSource; /** URI scheme + prefix for state-resource URIs. Default `'app://state/'`. */ resourceUriPrefix?: string; + /** Also expose a read-only `getState` **tool** (the portable hedge for + * tools-only hosts that don't support MCP resources — research-11 §3.2). + * Requires `views`. `true` uses defaults; pass `GetStateToolOptions` to + * customize the name/description/tiers. Default: off. */ + getStateTool?: boolean | GetStateToolOptions; } /** @@ -69,13 +78,33 @@ export function createMcpServer( ? { tiers: options.tiers } : {}; + // Optional read side. `views` also powers the getState TOOL below (the + // portable hedge for tools-only hosts) and the resources block further down. + const views = options.views; + const getStateOpts: GetStateToolOptions | null = + views && options.getStateTool + ? typeof options.getStateTool === 'object' + ? options.getStateTool + : {} + : null; + const getStateName = getStateOpts + ? getStateOpts.name ?? DEFAULT_GET_STATE_TOOL_NAME + : null; + server.setRequestHandler(ListToolsRequestSchema, async () => ({ - tools: buildToolsList(registry, listOptions), + tools: + views && getStateOpts + ? [...buildToolsList(registry, listOptions), buildGetStateTool(views, getStateOpts)] + : buildToolsList(registry, listOptions), })); server.setRequestHandler(CallToolRequestSchema, async (request) => { const params = request.params as { name: string; arguments?: unknown }; const args = params.arguments ?? {}; + if (views && getStateName && params.name === getStateName) { + const gs = callGetState(views, args); + return { content: gs.content, ...(gs.isError ? { isError: true } : {}) }; + } const response = await callTool(registry, params.name, args, options.context); return { content: response.content, @@ -90,7 +119,6 @@ export function createMcpServer( }); // ── Read side (optional) — resources/list + resources/read + subscribe ── - const views = options.views; if (views) { const listOpts: BuildResourcesListOptions = {}; if (options.tiers !== undefined) listOpts.tiers = options.tiers; diff --git a/packages/mcp/src/tools.ts b/packages/mcp/src/tools.ts index 798cf03..e9ace1f 100644 --- a/packages/mcp/src/tools.ts +++ b/packages/mcp/src/tools.ts @@ -26,6 +26,23 @@ export interface McpToolDescriptor { name: string; description?: string; inputSchema: Record; + /** MCP tool annotations — behavioural hints for the host's UX (auto-approve + * vs confirm). Hints, not security; a host MUST treat annotations from an + * untrusted server as untrusted (MCP spec; research-11 §4). Omitted here for + * command-tools until commands carry a side-effect class; the read-only + * `getState` tool sets `readOnlyHint`. */ + annotations?: McpToolAnnotations; +} + +/** The MCP tool-annotation vocabulary. Defaults are hostile — an omitted + * annotation reads as `destructiveHint: true` + `openWorldHint: true` on + * strict hosts, so opt in to `readOnlyHint` for low-friction reads. */ +export interface McpToolAnnotations { + title?: string; + readOnlyHint?: boolean; + destructiveHint?: boolean; + idempotentHint?: boolean; + openWorldHint?: boolean; } export interface BuildToolsListOptions {