diff --git a/.changeset/hide-unavailable-tools.md b/.changeset/hide-unavailable-tools.md new file mode 100644 index 0000000..4f040c2 --- /dev/null +++ b/.changeset/hide-unavailable-tools.md @@ -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`. diff --git a/.claude/skills/acture-ai/SKILL.md b/.claude/skills/acture-ai/SKILL.md index c612cb0..13db069 100644 --- a/.claude/skills/acture-ai/SKILL.md +++ b/.claude/skills/acture-ai/SKILL.md @@ -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` 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` (`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. diff --git a/.claude/skills/acture-mcp/SKILL.md b/.claude/skills/acture-mcp/SKILL.md index 961045e..4a85e51 100644 --- a/.claude/skills/acture-mcp/SKILL.md +++ b/.claude/skills/acture-mcp/SKILL.md @@ -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. diff --git a/.claude/skills/acture-palette-design/SKILL.md b/.claude/skills/acture-palette-design/SKILL.md index 1cb5b38..224f71a 100644 --- a/.claude/skills/acture-palette-design/SKILL.md +++ b/.claude/skills/acture-palette-design/SKILL.md @@ -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`). diff --git a/packages/ai-vercel/src/index.test.ts b/packages/ai-vercel/src/index.test.ts index c3fc7c8..53e422f 100644 --- a/packages/ai-vercel/src/index.test.ts +++ b/packages/ai-vercel/src/index.test.ts @@ -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 }; + 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', + ]); + }); +}); diff --git a/packages/ai-vercel/src/index.ts b/packages/ai-vercel/src/index.ts index c4a12b1..a26b9a6 100644 --- a/packages/ai-vercel/src/index.ts +++ b/packages/ai-vercel/src/index.ts @@ -41,6 +41,7 @@ import type { import { buildToolNameToIdMap, commandIdToToolName, + evaluateWhen, isFunctionWhen, isOk, } from 'acture'; @@ -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). */ @@ -113,10 +128,18 @@ function selectCommands( options: ToAIToolsOptions, ): readonly AnyCommandRecord[] { const excludeFn = options.excludeFunctionWhen ?? true; - const listOpts: Parameters[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)), + ); } /** @@ -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 diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 854b977..e611015 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -34,7 +34,8 @@ 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); @@ -42,7 +43,7 @@ await connectStdio(server); // 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) diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 8c35f50..34a6613 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -40,6 +40,7 @@ export { } from './tools.js'; export type { BuildToolsListOptions, + ContextSource, McpToolDescriptor, McpToolAnnotations, CallToolResponse, diff --git a/packages/mcp/src/server.test.ts b/packages/mcp/src/server.test.ts index 29b0e5e..1113168 100644 --- a/packages/mcp/src/server.test.ts +++ b/packages/mcp/src/server.test.ts @@ -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 { @@ -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[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(); + } + }); +}); diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index b847da4..59c0813 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -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, @@ -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`. @@ -77,6 +83,11 @@ export function createMcpServer( const listOptions: Parameters[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 => + 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. @@ -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) => { @@ -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 } : {}), diff --git a/packages/mcp/src/tools.test.ts b/packages/mcp/src/tools.test.ts index 3eed93a..8e0e4fb 100644 --- a/packages/mcp/src/tools.test.ts +++ b/packages/mcp/src/tools.test.ts @@ -180,3 +180,61 @@ describe('formatToolResponse', () => { expect(parsed.details).toBe('[unserializable]'); }); }); + +describe('buildToolsList — context hides unavailable commands (#67)', () => { + function gated() { + const registry = createRegistry(); + registry.registerAll([ + defineCommand({ id: 'app.always', title: 'Always', execute: () => ok(undefined) }), + defineCommand({ + id: 'app.edit.save', + title: 'Save', + when: 'editor.focused && !view.readonly', + execute: () => ok(undefined), + }), + ]); + return registry; + } + const names = (r: ReturnType, context?: Parameters[1]) => + buildToolsList(r, context).map((t) => t.name); + + it('lists every command when no context is given (unchanged)', () => { + expect(names(gated())).toEqual(['app_always', 'app_edit_save']); + }); + + it('leaves out a command whose when is false in the context', () => { + expect(names(gated(), { context: { editor: { focused: false } } })).toEqual(['app_always']); + expect(names(gated(), { context: { editor: { focused: true }, view: { readonly: true } } })).toEqual([ + 'app_always', + ]); + }); + + it('lists it when the when is true', () => { + expect(names(gated(), { context: { editor: { focused: true }, view: { readonly: false } } })).toEqual([ + 'app_always', + 'app_edit_save', + ]); + }); + + it('reads a context function on every build', () => { + const registry = gated(); + let ctx: Record = { editor: { focused: false } }; + const opts = { context: () => ctx }; + expect(names(registry, opts)).toEqual(['app_always']); + ctx = { editor: { focused: true }, view: { readonly: false } }; + expect(names(registry, opts)).toEqual(['app_always', 'app_edit_save']); + }); + + it('never runs a function when while listing (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(undefined), + }), + ); + expect(names(registry, { context: { user: 'agent-1' } })).toEqual(['app_always']); + }); +}); diff --git a/packages/mcp/src/tools.ts b/packages/mcp/src/tools.ts index 5fd990b..ac8c180 100644 --- a/packages/mcp/src/tools.ts +++ b/packages/mcp/src/tools.ts @@ -14,6 +14,7 @@ import type { import { buildToolNameToIdMap, commandIdToToolName, + evaluateWhen, isFunctionWhen, toJsonSchema, } from 'acture'; @@ -46,6 +47,15 @@ export interface McpToolAnnotations { openWorldHint?: boolean; } +/** A context, or a function returning the current one (read on every call, for + * contexts that change while the server or the model session lives). */ +export type ContextSource = Context | (() => Context); + +/** Resolve a {@link ContextSource} to the context to use right now. */ +export function resolveContext(source: ContextSource | undefined): Context | undefined { + return typeof source === 'function' ? source() : source; +} + export interface BuildToolsListOptions { /** Tier filter. Default `['stable']` per research-5. */ tiers?: readonly Tier[] | 'all'; @@ -53,6 +63,12 @@ export interface BuildToolsListOptions { * escape hatches are not exposable to MCP per research-5). Default * is true — exposing opaque availability is unsafe for AI clients. */ excludeFunctionWhen?: boolean; + /** When given, commands whose `when` is false in this context are left out + * of the list: a tool the model cannot usefully call only invites a failed + * call (`when_clause_failed` at dispatch, which stays as the backstop). + * Without it every command passing the other filters is listed, as before. + * A function is called on every build, so the list follows the context. */ + context?: ContextSource; } /** Banner format `[DEPRECATED — ]` is deterministic so downstream @@ -70,13 +86,17 @@ export function buildToolsList( options: BuildToolsListOptions = {}, ): McpToolDescriptor[] { const excludeFn = options.excludeFunctionWhen ?? true; - const listOpts: Parameters[0] = options.tiers !== undefined - ? { tiers: options.tiers } - : undefined; - const list = registry.list(listOpts); + const context = resolveContext(options.context); + // `registry.list` is asked for tiers only: given a context it would also run + // function `when`s, which are about to be dropped and may throw on a context + // they were never written for. Availability is checked here, after that. + const list = registry.list( + options.tiers !== undefined ? { tiers: options.tiers } : undefined, + ); const out: McpToolDescriptor[] = []; for (const cmd of list) { if (excludeFn && isFunctionWhen(cmd.when)) continue; + if (context !== undefined && !evaluateWhen(cmd.when, context)) continue; out.push(projectCommand(cmd)); } return out;