Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/mcp-getstate.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion .claude/skills/acture-ai-assistant/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>`, 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.
Expand Down
2 changes: 1 addition & 1 deletion .claude/skills/acture-mcp/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
13 changes: 12 additions & 1 deletion docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

---

Expand Down Expand Up @@ -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.
Expand Down
18 changes: 17 additions & 1 deletion packages/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 12 additions & 0 deletions packages/mcp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -35,6 +41,7 @@ export {
export type {
BuildToolsListOptions,
McpToolDescriptor,
McpToolAnnotations,
CallToolResponse,
} from './tools.js';

Expand All @@ -48,12 +55,17 @@ export {
buildResourcesList,
readResource,
viewIdToUri,
buildGetStateTool,
callGetState,
DEFAULT_RESOURCE_PREFIX,
DEFAULT_GET_STATE_TOOL_NAME,
} from './resources.js';
export type {
ViewSource,
ResourceView,
McpResourceDescriptor,
BuildResourcesListOptions,
ResourceContents,
GetStateToolOptions,
GetStateResponse,
} from './resources.js';
52 changes: 52 additions & 0 deletions packages/mcp/src/resources.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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');
});
});
93 changes: 93 additions & 0 deletions packages/mcp/src/resources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) }],
};
}
Loading
Loading