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
8 changes: 8 additions & 0 deletions .changeset/hide-unavailable-tools.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"acture-mcp-server": minor
"acture-ai-vercel": minor
---

`context` now also hides commands whose `when` is false from the tool list: `buildToolsList(registry, { context })`, `createMcpServer(registry, { context })` and `toAITools(registry, { context })`. It may be a function, read on every `tools/list`, build and dispatch, so the list follows a changing context. Without a context every command is listed, as before; `when_clause_failed` at dispatch remains the backstop.

Function-form `when`s are never run while listing (they stay excluded by `excludeFunctionWhen`). The list is fixed when built: a context that changes needs a rebuild (`toAITools` per step) or, on the MCP server, a `notifications/tools/list_changed`.
2 changes: 1 addition & 1 deletion .claude/skills/acture-ai/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ Name this fork explicitly when you surface the SDK choice — it changes the ada
### Decision 2 — agent-written vs package-reuse

- **Agent-written** — write the projection directly: iterate `registry.list({ tiers })`, build the SDK's tool-definition shape per command, and give each an `execute` that routes through `registry.dispatch`. ~30 lines, owned. This is the **only** path if the SDK is not the Vercel AI SDK — adapt the pattern in `packages/ai-vercel/src/index.ts` (a worked example, not an import).
- **Package-reuse — only if the SDK is the Vercel AI SDK** — install `acture-ai-vercel`. `toAITools(registry, { tiers?, excludeFunctionWhen?, context?, onDispatched? })` returns `Record<string, Tool>` ready to drop into `streamText({ tools })` / `generateText({ tools })`. Cost: a dependency to track (`ai` and `zod` are peer deps).
- **Package-reuse — only if the SDK is the Vercel AI SDK** — install `acture-ai-vercel`. `toAITools(registry, { tiers?, excludeFunctionWhen?, context?, onDispatched? })` returns `Record<string, Tool>` (`context` — an object or a function — leaves out commands whose `when` is false) ready to drop into `streamText({ tools })` / `generateText({ tools })`. Cost: a dependency to track (`ai` and `zod` are peer deps).

Surface both; follow a stated preference if one exists; otherwise ask. Record the choice in the project's adoption notes.

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 @@ -18,7 +18,7 @@ Unlike palette/hotkeys, MCP has effectively **one** SDK — `@modelcontextprotoc
### Decision 2 — agent-written vs package-reuse

- **Agent-written** — write the projection directly: a pure layer iterating `registry.list({ tiers })`, projecting each command through `toJsonSchema`, plus a `callTool` that routes through `registry.dispatch`. ~40 lines, owned. Then wire the SDK's `ListToolsRequestSchema` / `CallToolRequestSchema` handlers yourself. Adapt the pattern in `packages/mcp/src/tools.ts` + `packages/mcp/src/server.ts` (worked examples, not imports).
- **Package-reuse** — install `acture-mcp-server`. Two layers ready: the **pure functions** `buildToolsList(registry, opts)` / `callTool(registry, name, args, ctx?)` / `formatToolResponse(result)` — transport-agnostic, no SDK dependency — and the **Node stdio server** `createMcpServer(registry, { name, version, tiers?, context? })` / `connectStdio(server)`. Cost: a dependency to track (`@modelcontextprotocol/sdk` is a peer dep).
- **Package-reuse** — install `acture-mcp-server`. Two layers ready: the **pure functions** `buildToolsList(registry, opts)` / `callTool(registry, name, args, ctx?)` / `formatToolResponse(result)` — transport-agnostic, no SDK dependency — and the **Node stdio server** `createMcpServer(registry, { name, version, tiers?, context? })` (`context` — an object or a function read per request — hides commands whose `when` is false from `tools/list` and is passed to dispatch) / `connectStdio(server)`. Cost: a dependency to track (`@modelcontextprotocol/sdk` is a peer dep).

Surface both; follow a stated preference if one exists; otherwise ask. Record the choice in the project's adoption notes.

Expand Down
2 changes: 1 addition & 1 deletion .claude/skills/acture-palette-design/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ A command whose `when` is false is **unavailable**. What a surface does with it
| Surface | Unavailable command is… | Why |
| --- | --- | --- |
| Command palette (`acture-palette-react`) | **hidden**, when the host passes `context` (`registry.list({ context })` drops it; without `context`, `when` is not evaluated and an unavailable command fails at dispatch) | The palette is a search box; unavailable rows are noise and VS Code does the same. |
| AI tool list / MCP | **should be hidden; not implemented yet.** `acture-mcp-server` and `acture-ai-vercel` call `registry.list()` without a context, so they list every command and an unavailable one fails with `when_clause_failed` at dispatch | A tool the model cannot usefully call only invites a failed call. |
| AI tool list / MCP | **hidden.** `acture-mcp-server` (`buildToolsList({ context })`, `createMcpServer({ context })`) and `acture-ai-vercel` (`toAITools({ context })`) leave out commands whose `when` is false in the context, which may be a function read on every request; without a context every command is listed, and an unavailable one fails with `when_clause_failed` at dispatch (the backstop) | A tool the model cannot usefully call only invites a failed call. |
| Menu, toolbar, button, native menu | **shown, disabled** | These are spatial and persistent; a vanishing item breaks muscle memory and hides what the app can do. |

For the "shown, disabled" surfaces, build the projection from the **unfiltered** `registry.list()` and evaluate `when` yourself with `evaluateWhen(cmd.when, ctx)`. Map the result to `aria-disabled="true"` (not the `disabled` attribute, which takes the control out of the tab order, so keyboard users can no longer reach it and learn why it is off; APG menu items stay focusable), `AXEnabled: false` (macOS), `IsEnabled: false` (UIA), or Electron/Tauri `enabled: false`. A disabled command must not advertise a live shortcut (`aria-keyshortcuts`).
Expand Down
51 changes: 51 additions & 0 deletions packages/ai-vercel/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,3 +161,54 @@ describe('toToolNameMap', () => {
expect(map['app_old_thing']).toBeUndefined();
});
});

describe('toAITools — context hides unavailable commands (#67)', () => {
function gated() {
const registry = createRegistry();
registry.registerAll([
defineCommand({ id: 'app.always', title: 'Always', execute: () => ok(1) }),
defineCommand({ id: 'app.edit.save', title: 'Save', when: 'editor.focused', execute: () => ok(2) }),
]);
return registry;
}

it('projects every command without a context (unchanged)', () => {
expect(Object.keys(toAITools(gated()))).toEqual(['app_always', 'app_edit_save']);
});

it('leaves out a command whose when is false, in the tools and in the name map', () => {
const options = { context: { editor: { focused: false } } };
expect(Object.keys(toAITools(gated(), options))).toEqual(['app_always']);
expect(Object.keys(toToolNameMap(gated(), options))).toEqual(['app_always']);
});

it('reads a context function on each build and each dispatch', async () => {
const registry = gated();
let focused = false;
const options = { context: () => ({ editor: { focused } }) };
expect(Object.keys(toAITools(registry, options))).toEqual(['app_always']);
focused = true;
const tools = toAITools(registry, options);
expect(Object.keys(tools)).toEqual(['app_always', 'app_edit_save']);
const save = tools['app_edit_save']! as unknown as { execute: (a: unknown) => Promise<unknown> };
expect(await save.execute({})).toMatchObject({ ok: true, value: 2 });
focused = false; // dispatch reads the context again: the backstop still holds
expect(await save.execute({})).toMatchObject({ ok: false });
});

it('never runs a function when while building (it may throw on this context)', () => {
const registry = gated();
registry.register(
defineCommand({
id: 'app.palette.only',
title: 'Palette only',
when: (c) => Boolean((c.doc as { open: boolean }).open),
execute: () => ok(3),
}),
);
expect(Object.keys(toAITools(registry, { context: { editor: { focused: true } } }))).toEqual([
'app_always',
'app_edit_save',
]);
});
});
37 changes: 30 additions & 7 deletions packages/ai-vercel/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import type {
import {
buildToolNameToIdMap,
commandIdToToolName,
evaluateWhen,
isFunctionWhen,
isOk,
} from 'acture';
Expand All @@ -53,13 +54,27 @@ function deprecationBanner(reason?: string): string {
: DEPRECATION_PREFIX_BARE;
}

/** A context, or a function returning the current one (read on every call). */
export type ContextSource = Context | (() => Context);

function resolveContext(source: ContextSource | undefined): Context | undefined {
return typeof source === 'function' ? source() : source;
}

export interface ToAIToolsOptions {
/** Tier filter. Default `['stable']`. */
tiers?: readonly Tier[] | 'all';
/** Skip commands with function-form when-clause. Default true. */
excludeFunctionWhen?: boolean;
/** Static context forwarded to every dispatch. */
context?: Context;
/** The context for `when` clauses: commands whose `when` is false in it are
* left out of the tools (an unavailable tool only invites a failed call;
* `when_clause_failed` at dispatch remains the backstop), and it is
* forwarded to every dispatch. A function is called each time tools are
* built and each time one is dispatched, so rebuild the tools (e.g.
* `toAITools` per step) when the context changes: a tool hidden when the
* tools were built stays hidden, even if a step makes it available. Omit it and every
* command that passes the tier filter is projected. */
context?: ContextSource;
/** Called after each dispatch — useful for logging tool-call results.
* Receives the original `AnyCommandRecord` (with the canonical `cmd.id`,
* not the sanitized tool name). */
Expand Down Expand Up @@ -113,10 +128,18 @@ function selectCommands(
options: ToAIToolsOptions,
): readonly AnyCommandRecord[] {
const excludeFn = options.excludeFunctionWhen ?? true;
const listOpts: Parameters<Registry['list']>[0] =
options.tiers !== undefined ? { tiers: options.tiers } : undefined;
const list = registry.list(listOpts);
return excludeFn ? list.filter((cmd) => !isFunctionWhen(cmd.when)) : list;
const context = resolveContext(options.context);
// Tiers only: given a context `registry.list` would run function `when`s that
// are about to be dropped, and they may throw on a context they were never
// written for. Availability is checked after that.
const list = registry.list(
options.tiers !== undefined ? { tiers: options.tiers } : undefined,
);
return list.filter(
(cmd) =>
!(excludeFn && isFunctionWhen(cmd.when)) &&
(context === undefined || evaluateWhen(cmd.when, context)),
);
}

/**
Expand Down Expand Up @@ -159,7 +182,7 @@ function projectCommand(
// `cmd.id` (not the sanitized wire name) is what the registry
// dispatches on — sanitization is a wire-format concern, not a
// dispatch concern.
const result = await registry.dispatch(cmd.id, args, options.context);
const result = await registry.dispatch(cmd.id, args, resolveContext(options.context));
options.onDispatched?.(cmd, result);
// The AI SDK serializes whatever execute returns to JSON in the
// tool-result message. Pass the Result through unchanged — the
Expand Down
5 changes: 3 additions & 2 deletions packages/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,16 @@ const server = createMcpServer(registry, {
name: 'graph-editor',
version: '0.1.0',
// tiers: ['stable'], // default
// context: { user: 'agent-1' }, // optional static context
// context: { user: 'agent-1' }, // optional: hides commands whose `when` is false, and is passed to dispatch
// (or `context: () => currentContext()`, read on every request)
});

await connectStdio(server);
// inspect with:
// npx @modelcontextprotocol/inspector node ./dist/mcp.js
```

The server registers `tools/list` and `tools/call` handlers, and fires `notifications/tools/list_changed` whenever the registry's tier-filtered view changes.
The server registers `tools/list` and `tools/call` handlers, and fires `notifications/tools/list_changed` whenever the registry's tier-filtered view changes. It does not know when your `context` changes: if `context` hides commands, send `server.notification({ method: 'notifications/tools/list_changed' })` when it does. A `context` function that throws surfaces as a protocol error, not a tool error; keep it total.

## Read side — MCP resources (state exposure)

Expand Down
1 change: 1 addition & 0 deletions packages/mcp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export {
} from './tools.js';
export type {
BuildToolsListOptions,
ContextSource,
McpToolDescriptor,
McpToolAnnotations,
CallToolResponse,
Expand Down
83 changes: 83 additions & 0 deletions packages/mcp/src/server.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { describe, it, expect } from 'vitest';
import { createRegistry, defineCommand, ok } from 'acture';
import { createMcpServer } from './server.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import type { ViewSource } from './resources.js';

function makeViews(): ViewSource {
Expand Down Expand Up @@ -69,3 +71,84 @@ describe('createMcpServer — getState/command tool-name collision guard', () =>
).not.toThrow();
});
});

describe('createMcpServer — tools/list follows the context (#67)', () => {
async function listedNames(context: Parameters<typeof createMcpServer>[1]['context']) {
const registry = createRegistry();
registry.registerAll([
defineCommand({ id: 'app.always', title: 'Always', execute: () => ok(undefined) }),
defineCommand({
id: 'app.edit.save',
title: 'Save',
when: 'editor.focused',
execute: () => ok(undefined),
}),
]);
const server = createMcpServer(registry, { name: 't', version: '0.0.0', ...(context ? { context } : {}) });
const client = new Client({ name: 'c', version: '0.0.0' });
const [a, b] = InMemoryTransport.createLinkedPair();
await Promise.all([server.connect(a), client.connect(b)]);
try {
return (await client.listTools()).tools.map((t) => t.name);
} finally {
await client.close();
await server.close();
}
}

it('hides an unavailable command, and lists everything without a context', async () => {
expect(await listedNames({ editor: { focused: false } })).toEqual(['app_always']);
expect(await listedNames({ editor: { focused: true } })).toEqual(['app_always', 'app_edit_save']);
expect(await listedNames(undefined)).toEqual(['app_always', 'app_edit_save']);
});

it('reads a context function per request', async () => {
let focused = false;
const registry = createRegistry();
registry.register(
defineCommand({ id: 'app.edit.save', title: 'Save', when: 'editor.focused', execute: () => ok(undefined) }),
);
const server = createMcpServer(registry, {
name: 't',
version: '0.0.0',
context: () => ({ editor: { focused } }),
});
const client = new Client({ name: 'c', version: '0.0.0' });
const [a, b] = InMemoryTransport.createLinkedPair();
await Promise.all([server.connect(a), client.connect(b)]);
try {
expect((await client.listTools()).tools).toHaveLength(0);
focused = true;
expect((await client.listTools()).tools).toHaveLength(1);
} finally {
await client.close();
await server.close();
}
});

it('dispatches with the current value of a context function', async () => {
let focused = true;
const registry = createRegistry();
registry.register(
defineCommand({ id: 'app.edit.save', title: 'Save', when: 'editor.focused', execute: () => ok('saved') }),
);
const server = createMcpServer(registry, {
name: 't',
version: '0.0.0',
context: () => ({ editor: { focused } }),
});
const client = new Client({ name: 'c', version: '0.0.0' });
const [a, b] = InMemoryTransport.createLinkedPair();
await Promise.all([server.connect(a), client.connect(b)]);
try {
const done = await client.callTool({ name: 'app_edit_save', arguments: {} });
expect(done.isError).toBeFalsy();
focused = false;
const refused = await client.callTool({ name: 'app_edit_save', arguments: {} });
expect(refused.isError).toBe(true);
} finally {
await client.close();
await server.close();
}
});
});
29 changes: 20 additions & 9 deletions packages/mcp/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@ import {
UnsubscribeRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
import type { ReadResourceResult } from '@modelcontextprotocol/sdk/types.js';
import type { Context, Registry, Tier } from 'acture';
import { buildToolsList, callTool } from './tools.js';
import type { Registry, Tier } from 'acture';
import { buildToolsList, callTool, resolveContext } from './tools.js';
import type { ContextSource } from './tools.js';
import {
buildResourcesList,
buildGetStateTool,
Expand All @@ -37,10 +38,15 @@ export interface CreateMcpServerOptions {
version: string;
/** Tier filter applied to `tools/list`. Default `['stable']`. */
tiers?: readonly Tier[] | 'all';
/** Static context passed to `dispatch` on every tool call. For
* contexts that change at request time, prefer the per-call form
* (call `tools` directly instead of using this server wrapper). */
context?: Context;
/** The context for `when` clauses: commands whose `when` is false in it are
* left out of `tools/list` (an unavailable tool only invites a failed call;
* `when_clause_failed` at dispatch remains the backstop), and it is passed
* to `dispatch` on every tool call. A function is called on every request,
* so the list and the dispatches follow a context that changes. The server
* does not know when it changes: tell clients with
* `server.notification({ method: 'notifications/tools/list_changed' })`.
* Omit it and every command that passes the tier filter is listed. */
context?: ContextSource;
/** Optional READ side. Project a `ViewSource` (typed selectors over app
* state) as MCP resources — `resources/list` + `resources/read`, with
* `resources/subscribe` liveness wired to the source's `onStateChanged`.
Expand Down Expand Up @@ -77,6 +83,11 @@ export function createMcpServer(
const listOptions: Parameters<typeof buildToolsList>[1] = options.tiers !== undefined
? { tiers: options.tiers }
: {};
// The context is read per request, so `tools/list` is built with the current
// one. The name-collision check below deliberately ignores it: a command
// hidden now may be listed later, and must not collide then.
const listNow = (): ReturnType<typeof buildToolsList> =>
buildToolsList(registry, { ...listOptions, ...(options.context !== undefined ? { context: options.context } : {}) });

// Optional read side. `views` also powers the getState TOOL below (the
// portable hedge for tools-only hosts) and the resources block further down.
Expand Down Expand Up @@ -112,8 +123,8 @@ export function createMcpServer(
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools:
views && getStateOpts
? [...buildToolsList(registry, listOptions), buildGetStateTool(views, getStateOpts)]
: buildToolsList(registry, listOptions),
? [...listNow(), buildGetStateTool(views, getStateOpts)]
: listNow(),
}));

server.setRequestHandler(CallToolRequestSchema, async (request) => {
Expand All @@ -123,7 +134,7 @@ export function createMcpServer(
const gs = callGetState(views, args);
return { content: gs.content, ...(gs.isError ? { isError: true } : {}) };
}
const response = await callTool(registry, params.name, args, options.context);
const response = await callTool(registry, params.name, args, resolveContext(options.context));
return {
content: response.content,
...(response.isError ? { isError: true } : {}),
Expand Down
Loading
Loading