From 6d50dfa7d9cdd7d49d93e55a2c08c4866303680b Mon Sep 17 00:00:00 2001 From: Prudence-2217 Date: Sun, 25 Oct 2026 22:34:00 -0400 Subject: [PATCH 01/46] fix(mcpl): tool hints follow the server's statements, and a change counts as told only when an agent reads it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes to the RFC-014 host side (tool presence and toolsets, opt-in with toolHints) released in 0.27.0, in the toolset controller and tool presence. The framework's calls into the seam below change only as far as this needs; the wiring of each call site follows in the commits after this one. Requirements: Presence and moves - A tool's presence is what the latest tools/list states, from the moment it's read (RFC-014 §4.1, §6.1). A hint's presence stages nothing: a hinted move takes effect when a tools/list states it, as every rebuild's own list does. So a recovery repairs a lost move, a reconnect doesn't keep a stale one, and a move a later list reverses before the rebuild is dropped. - A presence a list changed is a move, told as a note of where the tool went at the next rebuild of the model's tool list. When the controller's own tools/list (connect, recovery) moved a tool, it asks the framework to rebuild then (ToolsetHostDeps.refreshTools, the framework's tools/list_changed path). - A list's visibility for a tool is applied by the presence the list states; a tool a list puts in a toolset moves like any other. - A definition a hint gives is the tool's current one, kept whatever the tool's presence when the hint is read. - A tool name listed twice is known by its first listing, as the framework's attribution has it. Reading lists and recovering - A tools/list reaches the controller once, whole, synchronously as its last page's response is read (RequestOptions.onRead), before anything read after it; so a hint in the same chunk applies after the list. Pages of lists read at once can't mix; a malformed or timed-out list applies nothing. tools/list leaves HINTED_METHODS. - A list whose pages carry different numeric mcpl/revs straddled a change: it's applied, then recovered from. One read while a recovery runs starts no further recovery (its earlier pages stand until the next change or list). - A hint or toolsets/list entry naming a tool tools/list hasn't listed starts a recovery; one naming a tool the operator's tool policy leaves out doesn't. A recovery's own toolsets/list starts no other, and a recovery whose tools/list failed reads no toolsets/list. - A toolsets/list result with no `open` list is ignored, not read as an empty statement. - The read hook is guarded, so a throw in it can't leave the request unsettled. Told once, in order - A change is taken only by a caller that shows it to an agent with its carrier (takeChange, called synchronously as the carrier is read, before the next macrotask); taking it marks nothing, and the caller says when it reached an agent (told). takeChangeText takes, renders and marks, for a caller that shows it at once. - Any change nobody took is told as a notice, in read order: taking or claiming a carrier first tells the unclaimed ones read before it, and a change look hands back is told at once. A change already told as a notice is empty when taken. - A result's change whose notice can't be written (no pushEvents grant, no agent) is dropped; the framework logs the refused notice, and look shows the state. A push's change is kept for the framework's push path, which takes it when the push gets there. A push whose handler throws still has its change told. - Every tools/list's change is told as it's read, the controller's own included. - An ended record is told once: its note travels with its id, is rendered only while the id is untold, and the id counts as told once a notice was written or a result carrying it was shown. The next statement that no longer reports an id forgets it (§5.3), which also forgets ids a replaced server or an evicted principal no longer reports. Only an end that will be noted stands in for an instance's closure note. - A tool's first sentence is owed until text describing it reaches the model: rendering for a notice or a push doesn't count it shown; told() counts the definitions a change describes. A notice describes a definition it shows twice only once. - look shows what its re-read changed only when its answer restates everything; otherwise, and when it fails, the change is told as a notice. - Reminders restate only servers whose changes may be told unasked (the pushEvents grant). Rendering - Rendering a server's definitions never throws: a description that isn't a string has no first sentence; a schema nested very deep is cut short in a signature, in a definition's identity (past 64 levels) and in look's answer for a tool or a toolset's options. - firstSentence cuts by code points, never between a surrogate pair. - setManifest drops a newly declared toolset from the host-opened groups; defKeyFor always returns a key. Limits, stated rather than built for: a rebuild whose own listing of the server fails leaves a move untold, and the tool list behind presence, until the next refresh; a move that changes the view is told in two notices (the view's at the read, the move's note at the rebuild); a result's change drained by a later read before its caller resumed, on a server without notices, is lost rather than shown with its result; told-once can fail for a look held across another server's re-read, an end told between a statement's build and its delivery, and ids that collide across principals under one configuration id. Told ids are kept in memory, so "once" holds while the host runs. Text: the README describes when a move takes effect, look's re-read, notices needing pushEvents, told-once's limits, RFC-008 class rules seeing the dynamic tool, and look showing a tool's description and input schema; TOOLSET_REQUEST_TIMEOUT_MS covers an open or close; the e2e file's header no longer claims vector 38. The changelog fragments say what changed since 0.27.0, by what a caller meets: one breaking entry (reminders now need the pushEvents grant), the changed behaviour, and the fixes. Tests: each requirement above has a test asserting what an agent, an operator or the server meets, except: a push refused before the push path reads it (needs a stopping host; its mechanism, a push nobody takes being told as a notice, is tested in-process); open's and close's marking; the read hook's guard (it throws only for a page too large to spread into one call's arguments); a throwing push handler; the push path's marking; and defKeyFor, which has no behaviour of its own. Each test added with a fix failed against the code before that fix, except those pinning behaviour that code already had (the RFC-014 vector cases added for coverage among them). Tests wait for the effect they read (a round trip, the agent going idle, one macrotask past a result); fixed waits stay only where a test asserts that nothing happens or waits out a request's timeout. Co-Authored-By: Claude Opus 5.5 --- README.md | 30 +- .../mcpl-toolsets-hint-state.breaking.md | 1 + .../mcpl-toolsets-hint-state.changed.md | 4 + changelog.d/mcpl-toolsets-hint-state.fixed.md | 6 + src/framework.ts | 50 +- src/mcpl/server-connection.ts | 52 +- src/mcpl/tool-presence.ts | 172 ++-- src/mcpl/tool-signature.ts | 42 +- src/mcpl/toolset-host.ts | 247 +++-- src/mcpl/toolsets-module.ts | 31 +- test/helpers/toolset-servers.ts | 3 + test/mcpl-tool-hints.test.ts | 904 +++++++++++++++++- test/mcpl-toolsets-e2e.test.ts | 4 +- 13 files changed, 1321 insertions(+), 225 deletions(-) create mode 100644 changelog.d/mcpl-toolsets-hint-state.breaking.md create mode 100644 changelog.d/mcpl-toolsets-hint-state.changed.md create mode 100644 changelog.d/mcpl-toolsets-hint-state.fixed.md diff --git a/README.md b/README.md index 4f29c39c6..a918597ce 100644 --- a/README.md +++ b/README.md @@ -448,27 +448,41 @@ framework registers a `toolsets` module with four constant tools: - `toolsets--act {server?, tool, instance?, args?}` calls a dynamic tool down the same path as a direct call of it (the agent's `allowedTools`, the server's tool policy, the state exchange, the reading of its result, RFC-007 - observers, who see the server's tool), with only the instance added, in + observers and RFC-008 class rules, which see the server's tool and its class), + with only the instance added, in `_meta["mcpl/instance"]`. It first checks `args` against the definition in force for that instance; that check is bounded, and leaves `pattern` and anything nested or branching too far to the server, which checks every call itself; - `toolsets--look {server?, toolset?, instance?, tool?}` restates what is open - and what can be opened, or one tool's whole definition; + and what can be opened, or one tool's description and input schema. It + re-reads the server first; what that changed is in the answer when look + restates everything, and otherwise told as a notice; - `toolsets--close {server?, toolset?, instance?}` closes it. `server` can be left out when only one server has the name. Hint changes apply as their message is read, in `rev` order, whatever becomes of the message (a -refused push, a result that came after its timeout). A skipped rev re-reads -`tools/list` and then `toolsets/list`, which the host also reads after every -(re)connect, adopting what the server kept open and never re-opening anything. +refused push, a result that came after its timeout). A move between static and +dynamic takes effect when the server's `tools/list` states it: `toolsets--act` +follows it then, and the tool list at its next rebuild, which the host asks for +at once when its own re-read (after a reconnect or a lost `rev`) made the move. +A skipped rev re-reads `tools/list` and then `toolsets/list`, which the host +also reads after every (re)connect, adopting what the server kept open and +never re-opening anything. What changed is told where it happened: after a call's own result, after the push that explains it, or as a notice (`kind: 'mcpl-toolsets'`, which wakes nothing) for a standalone `tools/hints`, a late result, an adoption, a recovery, or a call whose result doesn't go to the agent (one the framework or -a module makes itself). Notices need the server's `pushEvents` grant, like any delivery. A -new tool's first sentence is told the first time its definition appears. -Suggestions are shown, never acted on. Each `ended` id is told once. When an +a module makes itself). A call's change also goes as a notice, ahead of its +result, when a later change goes to the agent before the call's caller has +taken it, so changes reach the agent in the order they were read. Notices and reminders +need the server's `pushEvents` grant, like any delivery; without it, a change +that would go as a notice is dropped, and `toolsets--look` shows the state. A new tool's first sentence is told the first +time its definition appears. Suggestions are shown, never acted on. Each +`ended` id is told once while the +host runs (told ids are kept in memory), short of rare interleavings, such as a +`toolsets--look` overlapping other reads of the same server, that can tell one +twice. When an open toolset's announcements have left an agent's context (compressed or out of the window), the module's `gatherContext` restates what is open, as a stored `afterUser` injection (namespace `mcpl-toolsets`), at that agent's next diff --git a/changelog.d/mcpl-toolsets-hint-state.breaking.md b/changelog.d/mcpl-toolsets-hint-state.breaking.md new file mode 100644 index 000000000..6d58dcd59 --- /dev/null +++ b/changelog.d/mcpl-toolsets-hint-state.breaking.md @@ -0,0 +1 @@ +- **Hosts that set `toolHints: true` on an MCPL server without granting it `pushEvents`:** the reminder that restates a server's open toolsets at an agent's next start, once their announcement has left the context, now comes only for a server granted `pushEvents`, like every other notice of its changes. Grant `pushEvents` to that server to keep the reminders. diff --git a/changelog.d/mcpl-toolsets-hint-state.changed.md b/changelog.d/mcpl-toolsets-hint-state.changed.md new file mode 100644 index 000000000..069c19e0c --- /dev/null +++ b/changelog.d/mcpl-toolsets-hint-state.changed.md @@ -0,0 +1,4 @@ +- MCPL RFC-014 tool presence (`toolHints: true`): + - A move between static and dynamic takes effect when the server's `tools/list` states it, not when a hint announces it, and the agent is told where the tool went once a rebuild of the model's tool list has published it. When the host's own re-read (after a reconnect or a lost `rev`) moves a tool, it rebuilds the tool list at once. + - Each `ended` id is told once while the host runs: told ids are kept in memory, an id counts as told only once a notice or a result carrying it reached the agent, and it is forgotten once the server stops reporting it. Rare interleavings, such as a `toolsets--look` overlapping another read of the same server, can tell one twice. + - `toolsets--look` naming a tool shows its signature, description and input schema, as before; the README no longer calls that its whole definition. What look's own re-read of the server changed is in its answer when it restates everything, and otherwise told as a notice. diff --git a/changelog.d/mcpl-toolsets-hint-state.fixed.md b/changelog.d/mcpl-toolsets-hint-state.fixed.md new file mode 100644 index 000000000..bff44b84b --- /dev/null +++ b/changelog.d/mcpl-toolsets-hint-state.fixed.md @@ -0,0 +1,6 @@ +- MCPL RFC-014 tool presence (`toolHints: true`), fixes to how hint changes are applied and told: + - A `tools/list` is applied whole as its last page is read, so a hint read just after it isn't undone, and a malformed or timed-out list changes nothing. A paged list whose pages straddle a change, or a hint or `toolsets/list` entry naming a tool `tools/list` hasn't listed, is recovered from. + - Changes reach the agent in the order they were read, each once: a result's change nobody shows with it is told as a notice before any later change, and a new tool's first sentence counts as shown only once text describing it reached the model. + - A change that would go as a notice to a server without the `pushEvents` grant is dropped, and `toolsets--look` shows the state. + - A tool the operator's tool policy refuses never starts a recovery, and a tool listed twice is known by its first listing. + - Rendering a server's definitions never throws: a description that isn't a string is left out, and a schema or a toolset's `options` nested very deep is cut short, rather than failing a notice or `toolsets--look`. diff --git a/src/framework.ts b/src/framework.ts index 275a393cb..f97649828 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -22357,7 +22357,7 @@ export class AgentFramework { * itself when there's none, as for every server without toolHints. */ private withToolsetChange(serverId: string, result: R): R { - const change = this.toolsetHub?.get(serverId)?.changeText(result) ?? ''; + const change = this.toolsetHub?.get(serverId)?.takeChangeText(result) ?? ''; if (!change) return result; return { ...result, content: [...(Array.isArray(result.content) ? result.content : []), { type: 'text', text: change }] }; } @@ -27594,6 +27594,8 @@ export class AgentFramework { private toolsets(): ToolsetHub { return (this.toolsetHub ??= new ToolsetHub({ notify: (serverId, text, opts) => this.recordToolsetNotice(serverId, text, opts), + canNotify: (serverId) => CapabilityGrant.of(this.mcplServerRegistry?.getServer(serverId)).has('pushEvents'), + refreshTools: (serverId) => this.handleToolsListChanged(serverId), log: (line) => console.error(`[mcpl] ${line}`), listedName: (serverId, tool) => `${this.mcplServerConfigs.get(serverId)?.toolPrefix ?? `mcpl--${serverId}`}--${tool}`, })); @@ -27632,21 +27634,23 @@ export class AgentFramework { * it. The text is the server's (its tool definitions, titles, reasons), so * the notice names the server. */ - private recordToolsetNotice(serverId: string, text: string, opts: { invite: boolean }): void { + private recordToolsetNotice(serverId: string, text: string, opts: { invite: boolean }): boolean { const connection = this.mcplServerRegistry?.getServer(serverId); if (!CapabilityGrant.of(connection).has('pushEvents')) { console.error(`[mcpl] ${serverId}: a tool change was not told (pushEvents is not granted); ${META_TOOL_NAMES.look} shows it`); - return; + return false; } - if (this.agents.size === 0) return; + if (this.agents.size === 0) return false; try { this.addMessage( 'user', [{ type: 'text', text: `MCPL server "${serverId}", tool changes:\n${text}` }], { system: true, kind: 'mcpl-toolsets', serverId, ...(opts.invite ? { invite: true } : {}) } as MessageMetadata, ); + return true; } catch (error) { console.error(`[mcpl] ${serverId}: a tool change notice could not be written:`, error); + return false; } } @@ -29190,30 +29194,38 @@ export class AgentFramework { // RFC-014 §6.1 carrier 2: the tool change this push explains was // applied as it was read; it's told after the event's own content. A // push the host then refuses still tells its change, on its own. - const change = this.toolsetHub?.get(connection.id)?.changeText(params) ?? ''; + const toolsets = this.toolsetHub?.get(connection.id); + const cs = toolsets?.takeChange(params); + const change = toolsets && cs ? toolsets.presence.render(cs, { mark: false }) : ''; + const tellAlone = () => { + if (this.recordToolsetNotice(connection.id, change, { invite: false })) toolsets!.told(cs!); + }; const content = (params as { payload?: { content?: unknown } } | null)?.payload?.content; if (!change || !Array.isArray(content)) { - if (change) this.recordToolsetNotice(connection.id, change, { invite: false }); + if (change) tellAlone(); await this.pushHandler?.handlePushEvent(connection.id, params, responder as never); return; } - let refused = false; + let accepted = false; const told = { respond: (result: unknown) => { - if ((result as { accepted?: unknown } | null)?.accepted === false) refused = true; + accepted = (result as { accepted?: unknown } | null)?.accepted !== false; responder?.respond(result); }, respondError: (code: number, message: string, data?: unknown) => { - refused = true; (responder?.respondError as ((code: number, message: string, data?: unknown) => void) | undefined)?.(code, message, data); }, }; - await this.pushHandler?.handlePushEvent( - connection.id, - { ...params, payload: { ...params.payload, content: [...content, { type: 'text', text: change }] } } as PushEventParams, - told as never, - ); - if (refused) this.recordToolsetNotice(connection.id, change, { invite: false }); + try { + await this.pushHandler?.handlePushEvent( + connection.id, + { ...params, payload: { ...params.payload, content: [...content, { type: 'text', text: change }] } } as PushEventParams, + told as never, + ); + } finally { + if (accepted) toolsets!.told(cs!); + else tellAlone(); + } }); // Handle server-initiated inference requests (Step 6) @@ -29713,11 +29725,11 @@ export class AgentFramework { // so a call act makes is attributed, classed and state-exchanged as // a direct call of the tool would be. The list was read through the // server's tap, so its presence is current. - // A static↔dynamic move the server hinted applies here, at a list - // boundary (§4.2), and the agent is told where the tool went. + // The static↔dynamic moves the lists made since the last rebuild + // reach the tool list here (§4.2), and the agent is told where each + // tool went, when a notice can be written. const presence = this.toolsetHub?.get(server.id); - const moves = presence?.presence.active ? presence.applyMoves() : []; - if (moves.length > 0) this.recordToolsetNotice(server.id, moves.join('\n'), { invite: false }); + if (presence?.presence.active) presence.applyMoves(); const dynamic = (name: string): boolean => !!presence?.presence.active && presence.presence.presenceOf(name) === 'dynamic'; for (const tool of result.tools) { if (!isToolAllowed(tool.name, config)) continue; diff --git a/src/mcpl/server-connection.ts b/src/mcpl/server-connection.ts index d80e0b730..1162aacd9 100644 --- a/src/mcpl/server-connection.ts +++ b/src/mcpl/server-connection.ts @@ -240,10 +240,12 @@ export function toolsetDeclarationsOf(advertised: unknown): Record; result: unknown; awaited: boolean } /** A push/event (carrier 2), as read, before the plane gate or the grant * admit or refuse it. */ @@ -251,8 +253,8 @@ export type HintRead = /** A standalone tools/hints (carrier 3). */ | { kind: 'hints'; params: unknown }; -/** Requests whose results can carry hint state (RFC-014 §6.1, §4.1). */ -const HINTED_METHODS: ReadonlySet = new Set(['tools/call', 'toolsets/open', 'toolsets/close', 'toolsets/list', 'tools/list']); +/** Requests whose results can carry hint state, remembered for a late answer (RFC-014 §6.1). */ +const HINTED_METHODS: ReadonlySet = new Set(['tools/call', 'toolsets/open', 'toolsets/close', 'toolsets/list']); /** * A handshake that failed, and whose cleanup couldn't confirm that the child @@ -330,6 +332,7 @@ interface PendingRequest { resolve: (result: unknown) => void; reject: (error: Error) => void; method: string; + onRead?: (result: unknown) => void; /** Per-request timeout timer; cleared on response/close/timeout. */ timer?: ReturnType; } @@ -339,6 +342,9 @@ interface RequestOptions { timeoutMs?: number; /** Surface a late response even when it carries no checkpoint state. */ surfaceOrphanedResponse?: boolean; + /** Called with a successful result as its response is read, before + * anything read after it and before the request's promise settles. */ + onRead?: (result: unknown) => void; } /** A server→host message held while its plane is closed. `kept`: it came on @@ -452,8 +458,9 @@ export class McplServerConnection extends EventEmitter { * the message: a push the plane holds or the grant refuses, a result its * caller stopped waiting for. The tap sees every such message of the * current transport, synchronously, before anything routes it; a late - * answer's request is remembered past its timeout (hintedRequests) so its - * hints still apply. Null (the default) taps nothing, and the connection + * answer's request is remembered past its timeout (hintedRequests, while + * no more than 1,000 hinted requests await an answer) so its hints still + * apply. Null (the default) taps nothing, and the connection * behaves as it always has. */ private readTap: ((read: HintRead, epoch: number) => void) | null = null; @@ -1708,15 +1715,33 @@ export class McplServerConnection extends EventEmitter { * read it as a complete page). A page that isn't well formed (no `tools` array, a cursor * that isn't a string), or cursors that don't terminate (a repeat, or more * than {@link MAX_TOOL_LIST_PAGES} pages), are errors, never a partial or - * empty inventory published as complete. + * empty inventory published as complete. The read tap (RFC-014) is handed + * the complete inventory once, with the last page's `_meta`, as the last + * page's response is read, before anything read after it; a list that + * fails or times out hands it nothing. Its params say `straddled` when its + * pages carry different `mcpl/rev`s. */ async sendToolsList(): Promise<{ tools: McpToolDefinition[] }> { const tools: McpToolDefinition[] = []; const seen = new Set(); let cursor: string | undefined; let first: Record | undefined; + const read: McpToolDefinition[] = []; + const revs = new Set(); + const onRead = (result: unknown) => { + if (!result || typeof result !== 'object' || !Array.isArray((result as { tools?: unknown }).tools)) return; + const r = result as { tools: McpToolDefinition[]; nextCursor?: unknown; _meta?: unknown }; + read.push(...r.tools); + const rev = (r._meta as Record | undefined)?.['mcpl/rev']; + if (typeof rev === 'number') revs.add(rev); + if (r.nextCursor !== undefined && r.nextCursor !== null) return; + this.tap({ + kind: 'result', method: 'tools/list', params: revs.size > 1 ? { straddled: true } : {}, + result: { tools: read, ...(r._meta !== undefined ? { _meta: r._meta } : {}) }, awaited: true, + }); + }; for (let page = 0; page < MAX_TOOL_LIST_PAGES; page++) { - const result = await this.sendRequest('tools/list', cursor === undefined ? {} : { cursor }) as Record | null; + const result = await this.sendRequest('tools/list', cursor === undefined ? {} : { cursor }, { onRead }) as Record | null; const malformed = (why: string) => new Error(`MCPL server "${this.id}" sent a malformed tools/list page ${page + 1}: ${why}`); if (!result || typeof result !== 'object' || !Array.isArray(result.tools)) { @@ -2176,7 +2201,7 @@ export class McplServerConnection extends EventEmitter { } return new Promise((resolve, reject) => { - const pending: PendingRequest = { resolve, reject, method }; + const pending: PendingRequest = { resolve, reject, method, ...(options.onRead ? { onRead: options.onRead } : {}) }; const timeoutMs = options.timeoutMs ?? this.requestTimeoutMs; if (timeoutMs > 0) { pending.timer = setTimeout(() => { @@ -2654,6 +2679,11 @@ export class McplServerConnection extends EventEmitter { logValueRedacted(response.error.message, redact), ); } else { + try { + pending.onRead?.(response.result); + } catch (error) { + console.error(`MCPL server "${this.id}": reading ${pending.method}'s result failed: ${logDetail(describeCause(error))}`); + } pending.resolve(response.result); } } diff --git a/src/mcpl/tool-presence.ts b/src/mcpl/tool-presence.ts index 4da3383ba..ad4219c57 100644 --- a/src/mcpl/tool-presence.ts +++ b/src/mcpl/tool-presence.ts @@ -25,7 +25,7 @@ * * Ported from mcpl-cc-bridge's presence.ts, the reference host. */ -import { objectSignature, renderToolLines, toolDigest, type HintedTool } from './tool-signature.js'; +import { objectSignature, renderToolLines, toolDigest, type HintedTool, type Shown } from './tool-signature.js'; export interface ToolsetDecl { title?: string; @@ -69,15 +69,16 @@ export interface Change { /** The visible tools after the change. */ now: HintedTool[]; } -export interface ChangeSet { changes: Change[]; suggest: Suggestion[]; notes: string[] } +export interface ChangeSet { + changes: Change[]; + suggest: Suggestion[]; + notes: string[]; + /** Ended records' notes, by id: rendered while the id is untold, told once the set reaches an agent (markTold). */ + ended?: Array<{ id: string; note: string }>; +} export const emptyChange = (): ChangeSet => ({ changes: [], suggest: [], notes: [] }); -export const isEmptyChange = (c: ChangeSet): boolean => !c.changes.length && !c.suggest.length && !c.notes.length; -export const joinChanges = (...sets: ChangeSet[]): ChangeSet => ({ - changes: sets.flatMap((s) => s.changes), - suggest: sets.flatMap((s) => s.suggest), - notes: sets.flatMap((s) => s.notes), -}); +export const isEmptyChange = (c: ChangeSet): boolean => !c.changes.length && !c.suggest.length && !c.notes.length && !c.ended?.length; const isObj = (v: unknown): v is Record => typeof v === 'object' && v !== null && !Array.isArray(v); const arr = (v: unknown): unknown[] => (Array.isArray(v) ? v : []); @@ -90,7 +91,7 @@ const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v * the last + 1 (a message was lost unread): recover. * behind: at or below the last, not applied: recover. */ -export interface ApplyOutcome { applied: boolean; skipped: boolean; behind: boolean; suggest: Suggestion[] } +export interface ApplyOutcome { applied: boolean; skipped: boolean; behind: boolean; suggest: Suggestion[]; unlisted: boolean } /** What a hint `definition` may change (RFC §6.1): everything else stays the listed tool's. */ interface DefOverride { description?: string; inputSchema?: Record; annotations?: Record } @@ -117,10 +118,10 @@ export class ToolPresence { private visible = new Map>(); /** Definition overrides from hints: per key (an instance's own, or a toolset's), per tool. */ private defs = new Map>(); - /** `ended` ids told to the agent: once per id, across reconnects (RFC §5.3). */ + /** `ended` ids told to the agent, kept while the server still reports them (RFC §5.3). */ private toldEnded = new Set(); + /** Moves a tools/list made that the model's tool list doesn't show yet: told at its next rebuild. */ private pendingMoves = new Map(); - private moved = new Map(); /** Digests of definitions the model has been shown with their first sentence. */ readonly shown = new Set(); @@ -166,6 +167,7 @@ export class ToolPresence { } } this.declared = decl; + for (const name of Object.keys(decl)) this.groups.delete(name); this.surface = mcpl && isObj(mcpl.surface) ? { title: str(mcpl.surface.title), summary: str(mcpl.surface.summary) } : null; // RFC §5.1: removing a toolset, or turning its instances on or off, ends // its open instances; one that becomes undeclared starts closed under the @@ -190,21 +192,35 @@ export class ToolPresence { this.visible.clear(); this.defs.clear(); this.pendingMoves.clear(); - this.moved.clear(); } /** tools/list: every definition, visible or not (RFC §4.1). A tool the host's policy refuses is left out. */ setTools(list: HintedTool[]): void { - this.tools = new Map(list - .filter((t) => isObj(t) && typeof t.name === 'string' && this.allowed(t.name)) - .map((t) => [t.name, t])); + const seen = new Map([...this.tools.keys()].map((name) => [name, this.presenceOf(name)])); + const named = list.filter((t) => isObj(t) && typeof t.name === 'string'); + this.tools = new Map(); + for (const t of named) if (this.allowed(t.name) && !this.tools.has(t.name)) this.tools.set(t.name, t); for (const set of this.visible.values()) for (const n of [...set]) if (!this.tools.has(n)) set.delete(n); + // RFC §4.1: the list states each tool's presence, and presence follows + // it. A changed presence is a move the model's tool list shows at its + // next rebuild (§4.2; applyMoves); one the list reverses before then + // never reached that list. + for (const name of [...this.pendingMoves.keys()]) if (!this.tools.has(name)) this.pendingMoves.delete(name); + for (const name of this.active ? this.tools.keys() : []) { + const was = seen.get(name); + const listed = this.listedPresence(name); + if (this.pendingMoves.has(name)) { + if (this.pendingMoves.get(name) !== listed) this.pendingMoves.delete(name); + } else if (was !== undefined && was !== listed) { + this.pendingMoves.set(name, listed); + } + } // The list's `mcpl/visible` is the server's word only where nothing else // speaks: toolset-less dynamic tools and undeclared groups. For a declared // toolset it's an aggregate ("visible in some open instance"); per-instance // state comes from toolsets/list and hint changes (RFC §4.1, §5.3). for (const t of this.tools.values()) { - if (this.presenceOf(t.name) !== 'dynamic') continue; + if (this.listedPresence(t.name) !== 'dynamic') continue; const ts = this.toolsetOf(t.name); if (ts && this.isDeclared(ts)) continue; this.setVisible(keyOf(ts ?? NO_TOOLSET), t.name, t._meta?.['mcpl/visible'] !== false); @@ -234,12 +250,14 @@ export class ToolPresence { return m && typeof m['mcpl/toolset'] === 'string' && m['mcpl/toolset'] ? (m['mcpl/toolset'] as string) : undefined; } - /** static unless hinted dynamic; a toolset wins over any "static" (RFC §4.1); a move applies only at a boundary (applyMoves). */ + /** As the list states it: a toolset's tool is dynamic whatever a hint says (RFC §4.1). */ presenceOf(name: string): Presence { if (!this.active) return 'static'; + return this.listedPresence(name); + } + + private listedPresence(name: string): Presence { if (this.toolsetOf(name)) return 'dynamic'; - const moved = this.moved.get(name); - if (moved) return moved; return this.tools.get(name)?._meta?.['mcpl/presence'] === 'dynamic' ? 'dynamic' : 'static'; } @@ -332,12 +350,12 @@ export class ToolPresence { /** Apply a HintChanges (RFC §6.1) from any of its carriers, as it is read (see ApplyOutcome). */ apply(h: unknown): ApplyOutcome { - const none: ApplyOutcome = { applied: false, skipped: false, behind: false, suggest: [] }; + const none: ApplyOutcome = { applied: false, skipped: false, behind: false, suggest: [], unlisted: false }; if (!isObj(h) || typeof h.rev !== 'number' || !Number.isFinite(h.rev)) return none; if (h.rev <= this.rev) return { ...none, behind: true }; const skipped = h.rev !== this.rev + 1; this.rev = h.rev; - return { ...none, applied: true, skipped, suggest: this.applyBody(h) }; + return { ...none, applied: true, skipped, ...this.applyBody(h) }; } /** @@ -352,7 +370,8 @@ export class ToolPresence { return lost; } - private applyBody(h: Record): Suggestion[] { + private applyBody(h: Record): { suggest: Suggestion[]; unlisted: boolean } { + let unlisted = false; for (const e of arr(h.toolsets)) { if (!isObj(e) || !str(e.toolset) || !this.isDeclared(e.toolset as string)) continue; // undeclared toolsets are host-local const ts = e.toolset as string; @@ -366,20 +385,20 @@ export class ToolPresence { else if (e.state === 'closed') this.dropKey(key); } for (const e of arr(h.tools)) { - if (!isObj(e) || !str(e.name) || !this.tools.has(e.name as string)) continue; + if (!isObj(e) || !str(e.name)) continue; const name = e.name as string; - // A toolset's tool is dynamic whatever a hint says (RFC §4.1). - if ((e.presence === 'static' || e.presence === 'dynamic') && !this.toolsetOf(name)) { - // RFC §4.2: a move applies at a boundary the host chooses; until then the tool stays where it is. - if (e.presence !== this.presenceOf(name)) this.pendingMoves.set(name, e.presence); - else this.pendingMoves.delete(name); + if (!this.tools.has(name)) { + if (this.allowed(name)) unlisted = true; + continue; } - if (isObj(e.definition) && this.presenceOf(name) === 'dynamic') { + // A hint's presence stages nothing: a hinted move takes effect when + // the server's tools/list states it (RFC §4.1), as the rebuild's own + // list does. A definition it gives is the tool's current one, kept + // whatever its presence now, for when it's shown dynamic (§6.1). + if (isObj(e.definition)) { const key = this.defKeyFor(name, str(e.instance)); - if (key !== null) { - if (!this.defs.has(key)) this.defs.set(key, new Map()); - this.defs.get(key)!.set(name, overrideOf(e.definition)); - } + if (!this.defs.has(key)) this.defs.set(key, new Map()); + this.defs.get(key)!.set(name, overrideOf(e.definition)); } if (typeof e.visible === 'boolean') for (const key of this.keysFor(name, str(e.instance))) this.setVisible(key, name, e.visible); } @@ -388,14 +407,14 @@ export class ToolPresence { if (!isObj(s) || !str(s.toolset) || (s.action !== 'open' && s.action !== 'close')) continue; suggest.push({ toolset: s.toolset as string, ...(str(s.instance) ? { instance: str(s.instance) } : {}), action: s.action, reason: str(s.reason) ?? '' }); } - return suggest; + return { suggest, unlisted }; } /** * Where a definition lands (RFC §6.1): with `instance`, that instance's own; * without, the toolset's (for a toolset without instances, its only one's). */ - private defKeyFor(name: string, instance: string | undefined): string | null { + private defKeyFor(name: string, instance: string | undefined): string { const ts = this.toolsetOf(name); if (!ts) return keyOf(NO_TOOLSET); return keyOf(ts, this.declared[ts]?.instances ? instance : undefined); @@ -445,7 +464,7 @@ export class ToolPresence { * Returns what was lost, what the server says ended while no connection was * told, its suggestions, and whether its rev skipped one. */ - adopt(result: unknown): { lost: OpenRecord[]; ended: Ended[]; suggest: Suggestion[]; skipped: boolean } { + adopt(result: unknown): { lost: OpenRecord[]; ended: Ended[]; suggest: Suggestion[]; skipped: boolean; unlisted: boolean } { const had = [...this.open.values()]; // A full statement: a dynamic tool not stated visible is invisible. // Undeclared groups stay as the host has them open or closed. @@ -463,9 +482,10 @@ export class ToolPresence { } let suggest: Suggestion[] = []; let skipped = false; + let unlisted = false; const h = isObj(r._meta) ? r._meta['mcpl/hints'] : undefined; if (isObj(h)) { - suggest = this.applyBody(h); + ({ suggest, unlisted } = this.applyBody(h)); // Its own rev, at its place in order (RFC §6.1). if (typeof h.rev === 'number' && Number.isFinite(h.rev)) { skipped = h.rev !== this.rev + 1; @@ -477,29 +497,54 @@ export class ToolPresence { if (!isObj(e) || !str(e.toolset)) continue; ended.push({ ...(str(e.id) ? { id: str(e.id) } : {}), toolset: e.toolset as string, ...(str(e.instance) ? { instance: str(e.instance) } : {}), reason: str(e.reason) ?? '' }); } - return { lost: had.filter((o) => !this.open.has(keyOf(o.toolset, o.instance))), ended, suggest, skipped }; + // RFC §5.3: a record is reported on every toolsets/list until it expires, and its id is never + // reused, so an id the server no longer reports needn't be remembered. + const reported = new Set(ended.flatMap((e) => (e.id ? [e.id] : []))); + for (const id of [...this.toldEnded]) if (!reported.has(id)) this.toldEnded.delete(id); + return { lost: had.filter((o) => !this.open.has(keyOf(o.toolset, o.instance))), ended, suggest, skipped, unlisted }; } /** * What the agent should hear about an adoption: what ended while it was - * away, each `ended` id once however often the server repeats it (RFC - * §5.3), and what is closed now. + * away, each `ended` id until it has been told (RFC §5.3; markTold), and + * what is closed now. An ended record with an id is in `ended`, rendered + * while its id is untold. */ - adoptionNotes(lost: OpenRecord[], ended: Ended[]): string[] { + adoptionNotes(lost: OpenRecord[], ended: Ended[]): { notes: string[]; ended: Array<{ id: string; note: string }> } { const notes: string[] = []; - const endedKeys = new Set(ended.map((e) => keyOf(e.toolset, e.instance))); + const told: Array<{ id: string; note: string }> = []; + const endedKeys = new Set(); for (const e of ended) { + if (!e.id || !this.toldEnded.has(e.id)) endedKeys.add(keyOf(e.toolset, e.instance)); + const note = `${this.label(keyOf(e.toolset, e.instance))} ended${e.reason ? `: ${e.reason}` : ''}.`; if (e.id) { - if (this.toldEnded.has(e.id)) continue; - this.toldEnded.add(e.id); - } - notes.push(`${this.label(keyOf(e.toolset, e.instance))} ended${e.reason ? `: ${e.reason}` : ''}.`); + if (!this.toldEnded.has(e.id)) told.push({ id: e.id, note }); + } else notes.push(note); } for (const o of lost) { if (endedKeys.has(keyOf(o.toolset, o.instance))) continue; notes.push(`${this.label(keyOf(o.toolset, o.instance))} is closed: ${this.server} no longer has it open. ${this.openHint(o.toolset)} starts a new one.`); } - return notes; + return { notes, ended: told }; + } + + /** The agent has been told these ended records (a notice written, a result it reads); the next statement forgets any it no longer reports. */ + markTold(ended: ChangeSet['ended']): void { + for (const { id } of ended ?? []) this.toldEnded.add(id); + } + + /** The definitions a change set's tool blocks describe have reached the model. */ + markShown(cs: ChangeSet): void { + for (const c of cs.changes) { + if (c.closed || !(c.opened || c.appeared.length > 0 || c.redefined.length > 0 || c.gone.length > 0)) continue; + for (const t of c.now) this.shown.add(toolDigest(t)); + } + } + + /** A set's ended notes whose ids are still untold, each once. */ + untoldNotes(cs: ChangeSet): string[] { + const seen = new Set(); + return (cs.ended ?? []).filter(({ id }) => !this.toldEnded.has(id) && !seen.has(id) && !!seen.add(id)).map(({ note }) => note); } // ── host-made opens of undeclared groups (RFC §5.4) ── @@ -514,14 +559,14 @@ export class ToolPresence { // ── boundaries ── - /** Apply pending static↔dynamic moves (RFC §4.2) at a boundary the host chooses. Returns what moved. */ + /** Whether a move a tools/list made is still to be shown by a rebuild of the model's tool list. */ + get movesOwed(): boolean { + return this.pendingMoves.size > 0; + } + + /** At a rebuild of the model's tool list (RFC §4.2): the static↔dynamic moves the lists made since the last one. */ applyMoves(): Array<{ name: string; to: Presence }> { - const out: Array<{ name: string; to: Presence }> = []; - for (const [name, to] of this.pendingMoves) { - if (!this.tools.has(name)) continue; - this.moved.set(name, to); - out.push({ name, to }); - } + const out = [...this.pendingMoves].map(([name, to]) => ({ name, to })); this.pendingMoves.clear(); return out; } @@ -550,14 +595,21 @@ export class ToolPresence { } /** The tool block for one key: its label, how to call, and the visible tools (new or changed ones described). */ - toolBlock(key: string, tools: HintedTool[], opts: { describeAll?: boolean; headed?: boolean; title?: string } = {}): string[] { + toolBlock(key: string, tools: HintedTool[], opts: { describeAll?: boolean; headed?: boolean; title?: string; seen?: Shown } = {}): string[] { const head = opts.headed === false ? '' : `${this.label(key)}${opts.title ? ` ${opts.title}` : ''} `; if (!tools.length) return [`${head}no tools available right now.`]; - return [`${head}tools now — ${this.callHint(key)}:`, ...renderToolLines(tools, this.shown, { describeAll: opts.describeAll })]; + return [`${head}tools now — ${this.callHint(key)}:`, ...renderToolLines(tools, opts.seen ?? this.shown, { describeAll: opts.describeAll })]; } - /** What the model reads for a change set. `bare`: a key whose header the caller already printed. */ - render(cs: ChangeSet, opts: { bare?: string } = {}): string { + /** + * What the model reads for a change set. `bare`: a key whose header the + * caller already printed. `mark: false` renders without counting the + * definitions it describes as shown, for text that may not reach the model + * (markShown counts them once it has). + */ + render(cs: ChangeSet, opts: { bare?: string; mark?: boolean } = {}): string { + const local = new Set(); + const seen: Shown = opts.mark === false ? { has: (d) => this.shown.has(d) || local.has(d), add: (d) => { local.add(d); } } : this.shown; const lines: string[] = []; for (const c of cs.changes) { const bare = opts.bare === c.key; @@ -567,7 +619,7 @@ export class ToolPresence { } const title = (c.opened || c.retitled) && !bare ? c.title : undefined; const setChanged = c.opened || c.appeared.length > 0 || c.redefined.length > 0 || c.gone.length > 0; - if (setChanged) lines.push(...this.toolBlock(c.key, c.now, { headed: !bare, title })); + if (setChanged) lines.push(...this.toolBlock(c.key, c.now, { headed: !bare, title, seen })); else if (title) lines.push(`${this.label(c.key)} ${title}`); if (c.gone.length) lines.push(`now unavailable: ${c.gone.join(', ')}`); if (c.opened && !bare) { @@ -576,7 +628,7 @@ export class ToolPresence { } } for (const s of cs.suggest) lines.push(this.renderSuggestion(s)); - lines.push(...cs.notes); + lines.push(...this.untoldNotes(cs), ...cs.notes); return lines.join('\n'); } diff --git a/src/mcpl/tool-signature.ts b/src/mcpl/tool-signature.ts index ec3d3db99..d2a1deef6 100644 --- a/src/mcpl/tool-signature.ts +++ b/src/mcpl/tool-signature.ts @@ -1,10 +1,10 @@ /** * How a dynamic tool reads to a model when it reaches the conversation * instead of the tool list (MCPL RFC-014 §8.3): a compact signature, its - * first sentence the first time a definition is shown, the whole definition - * on request. Also the host's own argument check against a tool's current - * definition (RFC-014 §6.1: "the host checks arguments for that instance - * against it"). + * first sentence the first time a definition is shown, and on request its + * description, input schema and class. Also the host's own argument check + * against a tool's current definition (RFC-014 §6.1: "the host checks + * arguments for that instance against it"). * * Ported from mcpl-cc-bridge's tool-render.ts and schema-check.ts, the * reference host of RFC-014. Dependency-free. @@ -26,6 +26,7 @@ const isObj = (v: unknown): v is Record => typeof v === 'object function typeSig(schema: JsonSchema | undefined, depth = 0): string { if (!schema || typeof schema !== 'object') return 'any'; + if (depth > 8) return '…'; if (Array.isArray(schema.enum)) { // The enum is often the menu (a decision's options): all of it unless it is long. const vals = (schema.enum as unknown[]).map((v) => JSON.stringify(v)); @@ -94,22 +95,23 @@ export function objectSignature(schema: unknown): string { } /** First sentence of a description, bounded. */ -export function firstSentence(text: string | undefined, max = 140): string { - const t = (text ?? '').trim().replace(/\s+/g, ' '); +export function firstSentence(text: unknown, max = 140): string { + const t = (typeof text === 'string' ? text : '').trim().replace(/\s+/g, ' '); // A sentence ends at . ! ? followed by a capital, a bracket, or the end, so "e.g. \"D4\"" does not end it. const m = t.match(/^(.+?[.!?])(?=\s+[A-Z([]|$)/); - const s = m ? m[1]! : t; - return s.length > max ? s.slice(0, max - 1) + '…' : s; + const s = [...(m ? m[1]! : t)]; + return s.length > max ? s.slice(0, max - 1).join('') + '…' : s.join(''); } -function canonicalJson(value: unknown): string { +function canonicalJson(value: unknown, depth = 0): string { if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null'; - if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`; + if (depth > 64) return '"…"'; + if (Array.isArray(value)) return `[${value.map((v) => canonicalJson(v, depth + 1)).join(',')}]`; const obj = value as Record; return `{${Object.keys(obj) .sort() .filter((k) => obj[k] !== undefined) - .map((k) => `${JSON.stringify(k)}:${canonicalJson(obj[k])}`) + .map((k) => `${JSON.stringify(k)}:${canonicalJson(obj[k], depth + 1)}`) .join(',')}}`; } @@ -121,12 +123,15 @@ export function toolDigest(tool: HintedTool): string { .slice(0, 22); } +/** The definitions a model has been shown, by digest: has and add are all rendering needs. */ +export type Shown = { has(digest: string): boolean; add(digest: string): void }; + /** * Tool lines as the model reads them. A definition the model has been shown * gets its signature only; a new or changed one also gets its first * sentence. `shown` is updated. */ -export function renderToolLines(tools: HintedTool[], shown: Set, opts: { describeAll?: boolean; indent?: string } = {}): string[] { +export function renderToolLines(tools: HintedTool[], shown: Shown, opts: { describeAll?: boolean; indent?: string } = {}): string[] { const indent = opts.indent ?? ' '; return tools.map((t) => { const d = toolDigest(t); @@ -137,7 +142,16 @@ export function renderToolLines(tools: HintedTool[], shown: Set, opts: { }); } -/** The whole definition, for `look` naming a tool. */ +/** A server's value as JSON, or a note where it's nested too deeply to show. */ +export function jsonText(value: unknown, space?: number): string { + try { + return JSON.stringify(value, null, space) ?? ''; + } catch { + return '(nested too deeply to show)'; + } +} + +/** A tool's signature, description, class and input schema, for `look` naming a tool. */ export function renderToolDefinition(tool: HintedTool): string { const cls = tool._meta?.['mcpl/class']; return [ @@ -145,7 +159,7 @@ export function renderToolDefinition(tool: HintedTool): string { tool.description ?? '', ...(Array.isArray(cls) ? [`class: ${cls.join(', ')}`] : []), 'inputSchema:', - JSON.stringify(tool.inputSchema ?? {}, null, 2), + jsonText(tool.inputSchema ?? {}, 2), ] .filter((l) => l !== '') .join('\n'); diff --git a/src/mcpl/toolset-host.ts b/src/mcpl/toolset-host.ts index af6132c8a..f202f7635 100644 --- a/src/mcpl/toolset-host.ts +++ b/src/mcpl/toolset-host.ts @@ -5,10 +5,13 @@ * what changed; and the hub that holds every server's controller for the * meta-tools (toolsets-module.ts) and for reminders. * - * Hint changes apply as their message is read (RFC §6.1): the connection's - * read tap hands the controller every result of a hinted request (awaited or - * not), every push/event and every tools/hints, of the current transport, - * synchronously and in arrival order, before anything routes the message. + * Hint changes apply as their message is read (RFC §6.1); a static↔dynamic + * move takes effect when a tools/list states it (§4.1), and the model's tool + * list shows it at its next rebuild (§4.2). The connection's read tap hands + * the controller every result of a hinted request (awaited or not, while no + * more than 1,000 hinted requests await an answer), each tools/list whole, every push/event and every tools/hints, of the + * current transport, synchronously and in arrival order, before anything + * routes the message or anything read after it is handled. * What a message's changes did to the model's view is kept by the object * that carried them (`readChanges`), so whoever later handles that object * (the tool result, the push, the meta-tool) renders it next to the content. @@ -24,7 +27,7 @@ import type { McplServerConnection, HintRead } from './server-connection.js'; import type { McplServerConfig } from './types.js'; import { isToolAllowed } from './tool-policy.js'; import { - ToolPresence, emptyChange, isEmptyChange, joinChanges, keyOf, + ToolPresence, emptyChange, isEmptyChange, keyOf, type ChangeSet, type MetaToolNames, } from './tool-presence.js'; import type { HintedTool } from './tool-signature.js'; @@ -38,7 +41,7 @@ export const META_TOOL_NAMES: MetaToolNames = { close: `${TOOLSETS_MODULE}--close`, }; -/** How long an open, close or call is waited for. A late result still applies when it's read. */ +/** How long an open or close is waited for. A late result still applies when it's read. */ export const TOOLSET_REQUEST_TIMEOUT_MS = 60_000; /** A toolsets/list after a (re)connect or in a recovery. */ const LIST_TIMEOUT_MS = 15_000; @@ -54,7 +57,12 @@ export interface ToolsetHostDeps { /** A standalone notice into the conversation: a tools/hints change, a late * result's, a reconnect's adoption, a recovery's. `invite`: it suggests an * open. The host applies its usual rules for events. */ - notify(serverId: string, text: string, opts: { invite: boolean }): void; + notify(serverId: string, text: string, opts: { invite: boolean }): boolean; + /** Whether the server's changes may be put into the conversation unasked + * (its `pushEvents` grant): notices and reminders. */ + canNotify(serverId: string): boolean; + /** Rebuild the model's tool list from the server's (its tools/list_changed path). */ + refreshTools(serverId: string): void; /** The operator's log. */ log(line: string): void; /** How a static tool is named in the tool list (`prefix--tool`). */ @@ -62,8 +70,8 @@ export interface ToolsetHostDeps { } /** One server's RFC-014 state and traffic. Lives as long as the server's - * configuration, across reconnects and restarts (its `ended` ids and what - * the model was shown carry over). */ + * configuration, across reconnects and the server's restarts (its `ended` + * ids and what the model was shown carry over), in memory only. */ export class ToolsetController { readonly presence: ToolPresence; private connection: McplServerConnection | null = null; @@ -71,16 +79,10 @@ export class ToolsetController { private epoch = -1; /** What applying a message's hints changed, by the object that carried them. */ private readonly readChanges = new WeakMap(); - /** Results whose change no one has rendered yet (tellUnlessClaimed). */ - private readonly unclaimed = new Set(); + /** Results whose change no one has taken yet, in the order they were read, with their method (tellUnlessClaimed). */ + private readonly unclaimed = new Map(); /** eventIds of pushes read on this transport (bounded): a repeat's hints are ignored (RFC §6.1). */ private seenEvents = new Set(); - /** A paged tools/list being read. */ - private listPages: HintedTool[] | null = null; - /** Lists this controller asked for itself, whose change it delivers. */ - private ownLists = 0; - /** What lists read while one of its own was out changed, for it to tell. */ - private ownListChange: ChangeSet | null = null; private recoveryTimer: ReturnType | null = null; private recovering = false; private recoverAgain = false; @@ -153,7 +155,6 @@ export class ToolsetController { if (epoch === this.epoch) return; this.epoch = epoch; this.seenEvents = new Set(); - this.listPages = null; this.presence.beginConnection(connection.toolsetDeclarations, true); } @@ -193,25 +194,22 @@ export class ToolsetController { if (this.seenEvents.size > 1000) this.seenEvents.delete(this.seenEvents.values().next().value!); } // Applied now, before (and whatever) the host decides about the push. - this.readChanges.set(p, this.applyHints(p.hints, 'push')); + const cs = this.applyHints(p.hints, 'push'); + this.readChanges.set(p, cs); + this.tellUnlessClaimed(p, cs, 'push/event'); } private readResult(method: string, params: Record, res: Record, awaited: boolean): void { let cs: ChangeSet; switch (method) { case 'tools/list': { - const page = Array.isArray(res.tools) ? (res.tools as HintedTool[]) : []; - // A first page (no cursor) starts the list; a page with no next cursor ends it. - if (params.cursor === undefined || this.listPages === null) this.listPages = []; - this.listPages.push(...page); - if (res.nextCursor !== undefined && res.nextCursor !== null) return; - const tools = this.listPages; - this.listPages = null; - cs = this.readToolList(tools, res); - // The framework's own refresh (a list change, a reconnect) told here; - // a list this controller asked for is told by whoever asked. - if (this.ownLists === 0) this.deliver(cs); - else this.ownListChange = joinChanges(this.ownListChange ?? emptyChange(), cs); + if (!Array.isArray(res.tools)) return; + cs = this.readToolList(res.tools as HintedTool[], res); + if (params.straddled === true && !this.recovering) { + this.log('tools/list pages carried different revs, so its earlier pages are older than changes read between them: recovering'); + this.scheduleRecovery(); + } + this.deliver(cs); return; } case 'toolsets/list': @@ -271,6 +269,10 @@ export class ToolsetController { } else if (!r.applied) { this.log(`a malformed hint change (${source}) was ignored`); } + if (r.unlisted) { + this.log(`hint change rev ${rev} (${source}) names a tool tools/list hasn't listed: recovering from tools/list and toolsets/list`); + this.scheduleRecovery(); + } const cs = this.presence.diff(before, this.presence.view()); cs.suggest.push(...r.suggest); return cs; @@ -290,27 +292,63 @@ export class ToolsetController { /** toolsets/list, as read: a full statement of dynamic-tool state under its own rev (RFC §5.3, §6.1). */ private readToolsetList(res: Record): ChangeSet { if (!this.presence.active) return emptyChange(); + if (!Array.isArray(res.open)) { + this.log('a toolsets/list result with no open list was ignored'); + return emptyChange(); + } const before = this.presence.view(); - const { lost, ended, suggest, skipped } = this.presence.adopt(res); + const { lost, ended, suggest, skipped, unlisted } = this.presence.adopt(res); if (skipped && !this.recovering) { // Per-instance state is restated in full; what else was lost (presence) comes from tools/list. this.log('toolsets/list skipped a rev: re-reading tools/list'); this.scheduleRecovery(); } + if (unlisted && !this.recovering) { + this.log('toolsets/list names a tool tools/list hasn\'t listed: re-reading tools/list'); + this.scheduleRecovery(); + } const cs = this.presence.diff(before, this.presence.view()); cs.suggest.push(...suggest); // A lost toolset gets its note, not also a "closed" line. cs.changes = cs.changes.filter((c) => !lost.some((o) => keyOf(o.toolset, o.instance) === c.key)); - cs.notes.push(...this.presence.adoptionNotes(lost, ended)); + const told = this.presence.adoptionNotes(lost, ended); + cs.notes.push(...told.notes); + if (told.ended.length) cs.ended = [...(cs.ended ?? []), ...told.ended]; return cs; } - /** A change with nothing else to ride on, told as a notice. */ + /** A change with nothing else to ride on, told as a notice, after every result's change still unclaimed. */ private deliver(cs: ChangeSet): void { - if (isEmptyChange(cs)) return; - const text = this.presence.render(cs); - if (!text) return; - this.deps.notify(this.serverId, text, { invite: cs.suggest.some((s) => s.action === 'open') }); + this.tellUnclaimed(); + this.notice(cs); + } + + /** + * Unclaimed carriers' changes, told as notices in the order they were read, + * through `upTo` when given. When no notice can be written, a push keeps its + * change for the framework's push path, which takes it when the push gets + * there; a result's is dropped, whether or not its caller is still to + * resume. + */ + private tellUnclaimed(upTo?: object): void { + for (const [res, method] of [...this.unclaimed]) { + this.unclaimed.delete(res); + const cs = this.readChanges.get(res) ?? emptyChange(); + const sent = this.notice(cs); + if (sent !== 'refused' || method !== 'push/event') this.readChanges.delete(res); + if (sent === 'told') this.log(`${method}'s tool change wasn't shown with it: told as a notice`); + if (res === upTo) return; + } + } + + /** Whether a notice was written, there was nothing left to tell (`empty`), or one couldn't be written (`refused`). */ + private notice(cs: ChangeSet): 'told' | 'empty' | 'refused' { + if (isEmptyChange(cs)) return 'empty'; + const text = this.presence.render(cs, { mark: false }); + if (!text) return 'empty'; + if (!this.deps.notify(this.serverId, text, { invite: cs.suggest.some((s) => s.action === 'open') })) return 'refused'; + this.told(cs); + return 'told'; } /** @@ -332,31 +370,67 @@ export class ToolsetController { * run (the next macrotask), such as the result of a call the framework makes * itself (a reaction, a send, an accounted side effect), is told as a notice * instead, as a standalone tools/hints is (RFC §6.1: every change reaches the - * agent once). + * agent once), or dropped as tellUnclaimed says when none can be written. */ private tellUnlessClaimed(res: object, cs: ChangeSet, method: string): void { if (isEmptyChange(cs)) return; - this.unclaimed.add(res); + this.unclaimed.set(res, method); setImmediate(() => { - if (!this.unclaimed.delete(res)) return; - this.log(`${method} result's tool change wasn't shown with it: told as a notice`); - this.deliver(cs); + if (this.unclaimed.has(res)) this.tellUnclaimed(res); }); } - /** What applying `carrier`'s hints changed (a tool result, a push's params), - * rendered, for its handler to show with it; '' for nothing. Taking it - * means it's told there, not as a notice. */ - changeText(carrier: unknown): string { - if (!isObj(carrier)) return ''; - this.unclaimed.delete(carrier); - const cs = this.readChanges.get(carrier); - return cs ? this.presence.render(cs) : ''; + + /** + * The change a carrier's hints made (a tool result, a push's params), taken + * by a caller that shows it to an agent with the carrier: it isn't told as a + * notice, and the caller says when it has reached an agent (told). Call it + * synchronously as the carrier is read, before the next macrotask, when an + * unclaimed carrier's change goes out as a notice instead (tellUnclaimed + * says what happens when none can be written). A change already gone that + * way is empty here. Unclaimed carriers read before this one + * are told first, as notices, so no later change is shown before them. + */ + takeChange(carrier: unknown): ChangeSet { + return this.take(carrier); + } + + /** takeChange's change rendered, for a caller that shows it with the carrier + * at once, so it counts as told; '' for nothing. */ + takeChangeText(carrier: unknown): string { + const cs = this.take(carrier); + if (isEmptyChange(cs)) return ''; + const text = this.presence.render(cs); + this.told(cs); + return text; + } + + /** A change its taker showed to an agent: its ended records count as told, and the definitions it describes as shown. */ + told(cs: ChangeSet): void { + this.presence.markTold(cs.ended); + this.presence.markShown(cs); + } + + /** A change its taker couldn't show, told as a notice instead, or dropped when none can be written. */ + tell(cs: ChangeSet): void { + this.notice(cs); } - /** The change a carrier's hints made, or an empty one; taken as changeText's is. */ - changeOf(carrier: unknown): ChangeSet { + /** A carrier's change, taken to be told some other way (a notice this controller sends). */ + private claim(carrier: unknown): ChangeSet { + return this.take(carrier); + } + + private take(carrier: unknown): ChangeSet { if (!isObj(carrier)) return emptyChange(); + if (this.unclaimed.has(carrier)) { + let before: object | undefined; + for (const res of this.unclaimed.keys()) { + if (res === carrier) break; + before = res; + } + if (before) this.tellUnclaimed(before); + } this.unclaimed.delete(carrier); return this.readChanges.get(carrier) ?? emptyChange(); } @@ -388,11 +462,12 @@ export class ToolsetController { if (!connection || !connection.isConnected) return; this.recovering = true; try { - const tools = await this.ownToolList(connection); - if (connection !== this.connection) return; + const read = await this.ownToolList(connection); + // RFC §6.1: toolsets/list after tools/list, so every tool it names is known. + if (connection !== this.connection || !read) return; const lists = await this.ownToolsetList(connection, LIST_TIMEOUT_MS, 'recovery'); if (connection !== this.connection) return; - this.deliver(joinChanges(tools, lists)); + this.deliver(lists); } finally { this.recovering = false; if (this.recoverAgain) { @@ -402,27 +477,26 @@ export class ToolsetController { } } - private async ownToolList(connection: McplServerConnection): Promise { - if (this.ownLists++ === 0) this.ownListChange = null; + /** + * Whether the list was read; the tap applied it and told its change as it + * was read. When it moved a tool, the model's tool list is rebuilt now + * rather than at the next unrelated refresh. + */ + private async ownToolList(connection: McplServerConnection): Promise { try { - // The tap applied each page as it was read, and kept what the whole - // list changed (ownListChange). await connection.sendToolsList(); + if (this.presence.movesOwed) this.deps.refreshTools(this.serverId); + return true; } catch (error) { this.log(`tools/list failed: ${error instanceof Error ? error.message : String(error)}`); - } finally { - this.ownLists--; + return false; } - const collected = this.ownListChange ?? emptyChange(); - if (this.ownLists === 0) this.ownListChange = null; - else this.ownListChange = emptyChange(); - return collected; } private async ownToolsetList(connection: McplServerConnection, timeoutMs: number, why: string): Promise { try { const r = await connection.sendToolsetsList(timeoutMs); - return this.changeOf(r); + return this.claim(r); } catch (error) { this.log(`toolsets/list (${why}) failed: ${error instanceof Error ? error.message : String(error)}`); return emptyChange(); @@ -441,28 +515,29 @@ export class ToolsetController { this.sync(connection.transportEpoch, connection); if (!this.presence.active) return; const epoch = connection.transportEpoch; - const tools = await this.ownToolList(connection); + await this.ownToolList(connection); if (connection !== this.connection || connection.transportEpoch !== epoch) return; const lists = await this.ownToolsetList(connection, LIST_TIMEOUT_MS, 'after connecting'); if (connection !== this.connection || connection.transportEpoch !== epoch) return; - this.deliver(joinChanges(tools, lists)); + this.deliver(lists); } /** * Re-read what the server holds open (titles, visibility) without telling - * it: for look, which restates everything anyway. Returns notes for what - * the server no longer holds. + * it, for look. Returns what the re-read changed, taken from the notice it + * would otherwise get: look then either shows it (told) or hands it back + * (tell). Null when nothing was read. */ - async refreshOpen(): Promise { - if (!this.presence.active) return []; + async refreshOpen(): Promise { + if (!this.presence.active) return null; const connection = this.connection; - if (!connection || !connection.isConnected) return []; + if (!connection || !connection.isConnected) return null; try { const r = await connection.sendToolsetsList(REFRESH_TIMEOUT_MS); - return this.changeOf(r).notes; + return isObj(r) ? this.claim(r) : null; } catch (error) { this.log(`toolsets/list (look) failed: ${error instanceof Error ? error.message : String(error)}`); - return []; + return null; } } @@ -484,7 +559,7 @@ export class ToolsetController { key, ...(title ? { title } : {}), content: Array.isArray(res.content) ? res.content : [], - change: this.changeOf(res), + change: this.takeChange(res), adopted: res.adopted === true, }; } @@ -494,7 +569,7 @@ export class ToolsetController { const connection = this.live(); const r = await connection.sendToolsetsClose({ toolset, ...(instance ? { instance } : {}) }, TOOLSET_REQUEST_TIMEOUT_MS); const res = isObj(r) ? r : {}; - return { content: Array.isArray(res.content) ? res.content : [], change: this.changeOf(res) }; + return { content: Array.isArray(res.content) ? res.content : [], change: this.takeChange(res) }; } /** Open or close an undeclared group (RFC §5.4): the host's own act; the server isn't told. */ @@ -532,11 +607,15 @@ export class ToolsetController { this.deliver(this.presence.diff(before, this.presence.view())); } - /** Apply pending static↔dynamic moves (RFC §4.2) at a tool-list boundary, and say where they went. */ - applyMoves(): string[] { - return this.presence.applyMoves().map(({ name, to }) => to === 'dynamic' - ? `${name} left your tool list: it's announced when available now, and called with ${META_TOOL_NAMES.act} {server:"${this.serverId}", tool:"${name}", args}.` - : `${name} is in your tool list now, as ${this.deps.listedName(this.serverId, name)}.`); + /** At a rebuild of the model's tool list (RFC §4.2), tell where the tools the list moved went. */ + applyMoves(): void { + const moved = this.presence.applyMoves(); + if (!moved.length) return; + const cs = emptyChange(); + cs.notes.push(...moved.map(({ name, to }) => to === 'dynamic' + ? `${name} left your tool list: it's announced when available, and called with ${META_TOOL_NAMES.act} {server:"${this.serverId}", tool:"${name}", args}.` + : `${name} is in your tool list now, as ${this.deps.listedName(this.serverId, name)}.`)); + this.deliver(cs); } } @@ -615,7 +694,7 @@ export class ToolsetHub { /** The reminder due for `agentName`, or null when none is. */ reminderFor(agentName: string): string | null { if (!this.due.has(agentName)) return null; - const parts = this.presenting().map((c) => c.presence.reminder()).filter((r): r is string => !!r); + const parts = this.presenting().filter((c) => this.deps.canNotify(c.serverId)).map((c) => c.presence.reminder()).filter((r): r is string => !!r); if (parts.length === 0) return null; return `Open toolsets (a reminder: their earlier announcements have left your context):\n${parts.join('\n')}`; } diff --git a/src/mcpl/toolsets-module.ts b/src/mcpl/toolsets-module.ts index 322d74978..43cd15c9e 100644 --- a/src/mcpl/toolsets-module.ts +++ b/src/mcpl/toolsets-module.ts @@ -27,7 +27,7 @@ import type { ProcessEvent, ToolCall, ToolDefinition, ToolResult } from '../type import { unknownToolMessage } from '../modules/unknown-tool.js'; import { META_TOOL_NAMES, TOOLSETS_MODULE, type ToolsetController, type ToolsetHub } from './toolset-host.js'; import { keyOf, type ChangeSet } from './tool-presence.js'; -import { checkArgs, renderToolDefinition, type HintedTool } from './tool-signature.js'; +import { checkArgs, jsonText, renderToolDefinition, type HintedTool } from './tool-signature.js'; /** The namespace a reminder's stored injection carries. */ export const TOOLSETS_REMINDER_NAMESPACE = 'mcpl-toolsets'; @@ -55,7 +55,7 @@ const META_TOOLS: ToolDefinition[] = [ description: 'Call a tool that was announced in the conversation (a tool of an open toolset, or another dynamic tool); they are not in your tool list. ' + 'args are the arguments its signature shows; instance picks the open copy when several are open; server is needed only when two servers have the tool. ' + - 'Returns the server\'s result, then any change in which tools are available.', + 'Returns the server\'s result, then any change it made in which tools are available, unless that change already went out as a notice (or was due as one and none could be written). toolsets--look shows what is available now.', inputSchema: { type: 'object', properties: { @@ -303,6 +303,7 @@ export class ToolsetsModule implements Module { const close = p.closeHint(opened.key); if (close) tail.push(`${close} closes it.`); blocks.push({ type: 'text', text: tail.join('\n') }); + c.told(opened.change); return blocksResult(blocks); } if (p.undeclaredGroups.includes(toolset)) { @@ -336,9 +337,26 @@ export class ToolsetsModule implements Module { private async look(args: Record): Promise { const server = str(args.server); const targets = server ? [this.hub.get(server)].filter((c): c is ToolsetController => !!c && c.presents) : this.hub.presenting(); - const notes = (await Promise.all(targets.map((c) => c.refreshOpen().catch(() => [] as string[])))).flat(); - const result = this.lookNow(args, server, str(args.tool)); - return notes.length && !result.isError && typeof result.data === 'string' ? textResult(`${result.data}\n${notes.join('\n')}`) : result; + const read = (await Promise.all(targets.map(async (c) => ({ c, cs: await c.refreshOpen().catch(() => null) })))) + .filter((r): r is { c: ToolsetController; cs: ChangeSet } => r.cs !== null); + let result: ToolResult; + try { + result = this.lookNow(args, server, str(args.tool)); + } catch (error) { + for (const { c, cs } of read) c.tell(cs); + throw error; + } + const restates = !str(args.tool) && !str(args.toolset) && !str(args.instance); + if (result.isError || typeof result.data !== 'string' || !restates) { + for (const { c, cs } of read) c.tell(cs); + return result; + } + const lines = read.flatMap(({ c, cs }) => { + const shown = [...cs.suggest.map((s) => c.presence.renderSuggestion(s)), ...c.presence.untoldNotes(cs), ...cs.notes]; + c.told(cs); + return shown; + }); + return lines.length ? textResult(`${result.data}\n${lines.join('\n')}`) : result; } private lookNow(args: Record, server: string | undefined, tool: string | undefined): ToolResult { @@ -380,7 +398,7 @@ export class ToolsetsModule implements Module { `${c.serverId}/${toolset}${d.title ? ` — ${d.title}` : ''}${d.summary ? `: ${d.summary}` : ''}`, open.length ? `open: ${open.map((o) => o.instance ?? toolset).join(', ')}` : 'not open', ...(d.instances ? ['several can be open at once; name each with instance'] : []), - ...(d.options ? [`options: ${JSON.stringify(d.options)}`] : []), + ...(d.options ? [`options: ${jsonText(d.options)}`] : []), `${p.openHint(toolset)} opens it.`, ].join('\n')); } @@ -438,6 +456,7 @@ export class ToolsetsModule implements Module { } const key = keyOf(toolset!, instance); const change = p.render(closed.change) || `${p.label(key)} closed.`; + c.told(closed.change); return blocksResult([...asBlocks(closed.content), { type: 'text', text: change }]); } if (p.undeclaredGroups.includes(toolset!)) { diff --git a/test/helpers/toolset-servers.ts b/test/helpers/toolset-servers.ts index cbf849c75..38c704401 100644 --- a/test/helpers/toolset-servers.ts +++ b/test/helpers/toolset-servers.ts @@ -76,6 +76,8 @@ export async function startScriptedServer(opts: { tools: Json[]; /** tools/list's `_meta["mcpl/rev"]`, read at each list. */ listRev?: () => number | undefined; + /** Answers a tools/list itself when it returns true. */ + onList?: (msg: Json, peer: ScriptedPeer) => boolean; on?: (msg: Json, peer: ScriptedPeer) => void; }) { const wss = new WebSocketServer({ port: 0, host: '127.0.0.1' }); @@ -105,6 +107,7 @@ export async function startScriptedServer(opts: { if (msg.id !== undefined) peer.reply(msg.id, { accepted: true }); return; case 'tools/list': { + if (opts.onList?.(msg, peer)) return; const rev = opts.listRev?.(); peer.reply(msg.id, { tools: opts.tools, ...(rev !== undefined ? { _meta: { 'mcpl/rev': rev } } : {}) }); return; diff --git a/test/mcpl-tool-hints.test.ts b/test/mcpl-tool-hints.test.ts index ea18ce966..4494062fa 100644 --- a/test/mcpl-tool-hints.test.ts +++ b/test/mcpl-tool-hints.test.ts @@ -10,6 +10,10 @@ import assert from 'node:assert/strict'; import type { McplServerConfig } from '../src/mcpl/types.js'; import { serverConfigProblems } from '../src/mcpl/protocol-family.js'; import { assertServerConfigBooleans } from '../src/mcpl/transport.js'; +import { ToolPresence } from '../src/mcpl/tool-presence.js'; +import { firstSentence } from '../src/mcpl/tool-signature.js'; +import { META_TOOL_NAMES, ToolsetHub } from '../src/mcpl/toolset-host.js'; +import { ToolsetsModule, resolveAct } from '../src/mcpl/toolsets-module.js'; import { startScriptedServer, makeFramework, waitFor, callTool, resultText, windowText, type Json, type ScriptedPeer, @@ -66,6 +70,7 @@ async function setup(handler: Handler = () => false, config: Partial (advert.toolHints === true ? state.rev : undefined), + onList: (msg, peer) => handler(msg, peer, state) === true, on: (msg, peer) => { if (handler(msg, peer, state)) return; if (msg.method === 'toolsets/list') { peer.reply(msg.id, state.listResult()); return; } @@ -77,7 +82,7 @@ async function setup(handler: Handler = () => false, config: Partial fw.framework.getAllTools().some((t) => t.name === 'arc--status'), 'static tools listed'); if (({ toolHints: true, ...config }).toolHints === true && advert.toolHints === true) { await waitFor(() => server.received.some((m) => m.method === 'toolsets/list'), 'toolsets/list after connecting'); - await new Promise((r) => setTimeout(r, 50)); + await fw.framework.executeToolCall({ id: 'sync', name: 'arc--status', input: {} } as never); } const methods = () => server.received.map((m) => m.method).filter((m) => typeof m === 'string'); return { @@ -87,6 +92,12 @@ async function setup(handler: Handler = () => false, config: Partial new Promise((r) => setTimeout(r, ms)); +const roundTrip = (t: { fw: Awaited>['fw'] }) => t.fw.framework.executeToolCall({ id: 'sync', name: 'arc--status', input: {} } as never); +const nextMacrotask = () => new Promise((r) => setImmediate(r)); +const present = (tools: Json[], name: string, presence: 'static' | 'dynamic') => { + const tool = tools.find((x) => x.name === name)!; + tool._meta = { ...(tool._meta as Json | undefined), 'mcpl/presence': presence }; +}; const look = async (fw: Awaited>['fw'], input: Json = { server: 'arc' }) => resultText(await callTool(fw.framework, 'toolsets--look', input)); const push = (peer: ScriptedPeer, eventId: string, text: string, extra: Json = {}) => peer.request('push/event', { featureSet: 'arc.events', eventId, timestamp: new Date().toISOString(), payload: { content: [{ type: 'text', text }] }, ...extra, @@ -128,13 +139,12 @@ test('vector 3, 11: a push\'s hints apply on arrival whatever is decided about t // Refused (an undeclared feature set): applied all the same, and told on its own. const refused = await push(t.server.peer, 'p2', 'nobody hears this', { featureSet: 'arc.nope', hints: t.state.hint([{ name: 'peek', visible: false }]) }); assert.ok(refused.error, 'the push was refused'); - assert.doesNotMatch(await look(t.fw), /peek\(/, 'the refused push\'s change applied'); - await waitFor(() => windowText(t.fw.framework).some((x) => x.includes('tool changes') && x.includes('now unavailable: peek')), 'its change told as a notice'); + await waitFor(() => windowText(t.fw.framework).some((x) => x.includes('tool changes') && x.includes('now unavailable: peek')), 'the refused push\'s change applied, and told as a notice'); // The next change has the next rev and applies without any recovery. const lists = t.methods().filter((m) => m === 'tools/list').length; t.server.peer.notify('tools/hints', t.state.hint([{ name: 'peek', visible: true }])); + await waitFor(() => windowText(t.fw.framework).some((x) => x.includes('tool changes') && x.includes('peek()')), 'the next change applied, and told'); await settle(400); - assert.match(await look(t.fw), /peek\(\)/); assert.equal(t.methods().filter((m) => m === 'tools/list').length, lists, 'no recovery'); } finally { await t.done(); } }); @@ -157,7 +167,7 @@ test('vector 4, 12: a skipped rev recovers through tools/list, then toolsets/lis await waitFor(() => t.server.received.slice(before).some((m) => m.method === 'toolsets/list'), 'recovery read toolsets/list'); const recovery = t.server.received.slice(before).map((m) => m.method).filter((m) => m === 'tools/list' || m === 'toolsets/list'); assert.deepEqual(recovery, ['tools/list', 'toolsets/list'], 'tools/list first'); - await settle(); + await roundTrip(t); // The instance's own definition from the statement is what the host checks against. const refused = await callTool(t.fw.framework, 'toolsets--act', { tool: 'play', instance: 'g1', args: { point: 'D4' } }); assert.ok(refused.isError); @@ -179,7 +189,7 @@ test('vector 10: an undeclared toolset is the host\'s to open; no toolsets/open const again = await callTool(t.fw.framework, 'toolsets--act', { server: 'arc', tool: 'dig', args: {} }); assert.ok(again.isError, 'gated again once closed'); assert.ok(!t.methods().includes('toolsets/open') && !t.methods().includes('toolsets/close'), 'the server was never told'); - assert.equal(t.server.received.filter((m) => m.method === 'tools/call').length, 1, 'only the call made while open was sent'); + assert.equal(t.server.received.filter((m) => m.method === 'tools/call' && (m.params as Json).name === 'dig').length, 1, 'only the call made while open was sent'); } finally { await t.done(); } }); @@ -189,16 +199,17 @@ test('vector 13: an ended record is told once, however often the host reconnects t.state.ended = [{ id: 'e7', toolset: 'game', instance: 'g9', reason: 'idle for 30 minutes' }]; for (let i = 0; i < 2; i++) { const lists = t.methods().filter((m) => m === 'toolsets/list').length; + t.state.rev = 0; t.server.peer.ws.terminate(); await waitFor(() => t.methods().filter((m) => m === 'toolsets/list').length > lists, `toolsets/list after reconnect ${i + 1}`); - await settle(150); + await roundTrip(t); } const told = windowText(t.fw.framework).filter((x) => x.includes('[arc/game:g9] ended: idle for 30 minutes')); assert.equal(told.length, 1, 'told once'); } finally { await t.done(); } }); -test('vectors 21, 22: a definition changes only its instance\'s description, schema and annotations', async () => { +test('vectors 21, 22: a definition changes only its instance\'s description and schema', async () => { const t = await setup((msg, peer, state) => { if (msg.method !== 'toolsets/open') return false; const p = msg.params as Json; @@ -214,20 +225,20 @@ test('vectors 21, 22: a definition changes only its instance\'s description, sch name: 'play', instance: 'a', visible: true, definition: { name: 'renamed', description: 'Play a legal point.', inputSchema: { type: 'object', properties: { point: { enum: ['A1'] } }, required: ['point'] }, _meta: { 'mcpl/toolset': 'build', 'mcpl/presence': 'static' } }, }])); - await settle(); + await roundTrip(t); const names = t.fw.framework.getAllTools().map((x) => x.name); assert.ok(!names.includes('arc--play') && !names.includes('arc--renamed'), 'still out of the tool list (presence unchanged)'); - const all = await look(t.fw); - assert.match(all, /\[arc\/game:a\][^\n]*\n\s+play\(point: "A1"\)/, 'a has its own definition, under its own name'); - assert.match(all, /\[arc\/game:b\][^\n]*\n\s+play\(point: string\)/, 'b keeps the declared one'); - assert.doesNotMatch(all, /\[arc\/build\]/, 'no other toolset'); const onA = await callTool(t.fw.framework, 'toolsets--act', { tool: 'play', instance: 'a', args: { point: 'B2' } }); assert.ok(onA.isError, 'checked against a\'s definition'); const onB = await callTool(t.fw.framework, 'toolsets--act', { tool: 'play', instance: 'b', args: { point: 'B2' } }); assert.ok(!onB.isError, resultText(onB)); - const sent = t.server.received.filter((m) => m.method === 'tools/call'); + const sent = t.server.received.filter((m) => m.method === 'tools/call' && (m.params as Json).name === 'play'); assert.equal(sent.length, 1); assert.deepEqual((sent[0]!.params as Json)._meta, { 'mcpl/instance': 'b' }); + const all = await look(t.fw); + assert.match(all, /\[arc\/game:a\][^\n]*\n\s+play\(point: "A1"\)/, 'a has its own definition, under its own name'); + assert.match(all, /\[arc\/game:b\][^\n]*\n\s+play\(point: string\)/, 'b keeps the declared one'); + assert.doesNotMatch(all, /\[arc\/build\]/, 'no other toolset'); } finally { await t.done(); } }); @@ -269,12 +280,14 @@ test('vector 27: open, close and list results take the next rev; tools/list take const before = lists(); const opened = await callTool(t.fw.framework, 'toolsets--open', { server: 'arc', toolset: 'build' }); assert.match(resultText(opened), /place\(x\?: int\)/); - await t.fw.framework.executeToolCall({ id: 'refresh', name: 'arc--status', input: {} } as never); + t.server.peer.notify('notifications/tools/list_changed', {}); + await waitFor(() => lists() > before, 'a tools/list between the open\'s rev and the close\'s'); + await roundTrip(t); const closed = await callTool(t.fw.framework, 'toolsets--close', { server: 'arc' }); assert.match(resultText(closed), /packed up\n\[arc\/build\] closed\. Now unavailable: place\./); await look(t.fw); // a toolsets/list, rev after the close's await settle(400); - assert.equal(lists(), before, 'every rev was the next one: no recovery'); + assert.equal(lists(), before + 1, 'every rev was the next one: no recovery'); } finally { await t.done(); } }); @@ -366,9 +379,8 @@ test('reminders: an open toolset whose announcement left the context is restated const n = t.fw.membrane.calls.length; if (!scripted) t.fw.membrane.pushResponse(createMockResponse([{ type: 'text', text: 'ok' }])); t.fw.framework.pushEvent({ type: 'external-message', source: 'test', content: [{ type: 'text', text: 'go' }], metadata: {}, triggerInference: true } as never); - await waitFor(() => t.fw.membrane.calls.length > n, 'a request'); - await settle(150); - return JSON.stringify(t.fw.membrane.calls[t.fw.membrane.calls.length - 1]!.messages); + await waitFor(() => t.fw.membrane.calls.length > n && t.fw.framework.getAgent('scout')!.state.status === 'idle', 'the turn\'s request made and the turn ended'); + return JSON.stringify(t.fw.membrane.calls[n]!.messages); }; try { // The agent opens the toolset itself; the announcement is in its tool result. @@ -479,7 +491,7 @@ test('act and a direct call take one dispatch path: state exchange, lifecycle, r const results = JSON.stringify(fw.membrane.lastStream!.receivedToolResults); assert.match(results, /play ran/); assert.match(results, /now unavailable: play/); - await settle(100); + await nextMacrotask(); assert.ok(!windowText(fw.framework).some((t) => t.includes('tool changes')), 'told once, in the result'); } finally { await fw.stop(); @@ -489,8 +501,9 @@ test('act and a direct call take one dispatch path: state exchange, lifecycle, r }); test('a hint change in the result of a call whose result the agent doesn\'t see is told as a notice', async () => { + let reacting = false; const t = await setup((msg, peer, state) => { - if (msg.method !== 'tools/call') return false; + if (msg.method !== 'tools/call' || !reacting) return false; peer.reply(msg.id, { content: [{ type: 'text', text: 'reacted' }], _meta: { 'mcpl/hints': state.hint([{ name: 'peek', visible: true }]) } }); return true; }); @@ -498,6 +511,7 @@ test('a hint change in the result of a call whose result the agent doesn\'t see // As the framework's own calls (a reaction, a send) are made: straight on the connection. const connection = (t.fw.framework as unknown as { mcplServerRegistry: { getServer(id: string): { sendToolsCall(n: string, a: object): Promise } } }) .mcplServerRegistry.getServer('arc'); + reacting = true; await connection.sendToolsCall('status', {}); await waitFor(() => windowText(t.fw.framework).some((x) => x.includes('tool changes') && x.includes('peek()')), 'told as a notice'); } finally { await t.done(); } @@ -540,3 +554,851 @@ test('the host\'s argument check is bounded: no server pattern runs, and deep or assert.equal(checkArgs(wide, null), null); assert.ok(Date.now() - t0 < 200, 'bounded'); }); + +const notices = (fw: Awaited>['fw']) => windowText(fw.framework).filter((x) => x.includes('tool changes')); + +test('a tools/list with a malformed page changes nothing the host believes', async () => { + let malformed = false; + const t = await setup((msg, peer, state) => { + if (msg.method !== 'tools/list' || !malformed) return false; + if ((msg.params as Json | undefined)?.cursor === undefined) peer.reply(msg.id, { tools: TOOLS.slice(0, 3), nextCursor: 'p2', _meta: { 'mcpl/rev': state.rev } }); + else peer.reply(msg.id, { nextCursor: null }); + return true; + }); + try { + t.server.peer.notify('tools/hints', t.state.hint([{ name: 'peek', visible: true }])); + await waitFor(() => notices(t.fw).some((x) => x.includes('peek()')), 'peek announced'); + malformed = true; + t.server.peer.notify('notifications/tools/list_changed', {}); + await waitFor(() => t.server.received.some((m) => m.method === 'tools/list' && (m.params as Json | undefined)?.cursor === 'p2'), 'the second page asked for'); + // The second page's answer was sent before look's request, on an ordered transport. + assert.match(await look(t.fw), /peek\(\)/, 'peek is still known and visible'); + assert.ok(!notices(t.fw).some((x) => x.includes('now unavailable: peek')), 'nothing was told gone'); + } finally { await t.done(); } +}); + +test('a toolsets/list result with no open list is ignored', async () => { + let malformed = false; + const t = await setup((msg, peer, state) => { + if (msg.method === 'toolsets/open') { + state.open = [{ toolset: 'build', title: 'Workshop' }]; + peer.reply(msg.id, { toolset: 'build', title: 'Workshop', _meta: { 'mcpl/hints': state.hint([{ name: 'place', visible: true }]) } }); + return true; + } + if (msg.method === 'toolsets/list' && malformed) { + peer.reply(msg.id, { _meta: { 'mcpl/hints': { rev: state.next(), tools: [] } } }); + return true; + } + return false; + }); + try { + await callTool(t.fw.framework, 'toolsets--open', { server: 'arc', toolset: 'build' }); + malformed = true; + const after = await look(t.fw); + assert.match(after, /\[arc\/build\] Workshop tools now/, 'build is still open, with its tools'); + assert.doesNotMatch(after, /is closed/); + } finally { await t.done(); } +}); + +test('an ended record is told once it reaches the agent: a notice not written leaves it for look', async () => { + const t = await setup((msg, _peer, state) => { + if (msg.method === 'toolsets/list') state.ended = [{ id: 'e1', toolset: 'game', instance: 'g1', reason: 'the game is over' }]; + return false; + }, { disabledCapabilities: ['pushEvents'] }); + try { + assert.match(await look(t.fw), /\[arc\/game:g1\] ended: the game is over\./, 'look tells it'); + assert.ok(!notices(t.fw).length, 'without pushEvents no notice was written'); + assert.doesNotMatch(await look(t.fw), /ended: the game is over/, 'and only once'); + } finally { await t.done(); } +}); + +test('a told ended id is forgotten once the server stops reporting it, so a server that reuses the id after forgetting it is heard again', async () => { + let ended: Json[] = [{ id: 'e1', toolset: 'game', instance: 'g1', reason: 'first game over' }]; + const t = await setup((msg, _peer, state) => { + if (msg.method === 'toolsets/list') state.ended = ended; + return false; + }); + try { + await waitFor(() => notices(t.fw).some((x) => x.includes('[arc/game:g1] ended: first game over.')), 'the first end told'); + ended = []; + await look(t.fw); + ended = [{ id: 'e1', toolset: 'game', instance: 'g2', reason: 'second game over' }]; + assert.match(await look(t.fw), /\[arc\/game:g2\] ended: second game over\./); + } finally { await t.done(); } +}); + +test('a result\'s change told as a notice comes before a later change read after it', async () => { + const t = await setup((msg, peer, state) => { + if (msg.method !== 'tools/call') return false; + peer.reply(msg.id, { content: [{ type: 'text', text: 'ok' }], _meta: { 'mcpl/hints': state.hint([{ name: 'peek', visible: false }]) } }); + peer.notify('tools/hints', state.hint([{ name: 'peek', visible: true }])); + return true; + }); + try { + t.server.peer.notify('tools/hints', t.state.hint([{ name: 'peek', visible: true }])); + await waitFor(() => notices(t.fw).some((x) => x.includes('peek()')), 'peek announced'); + const before = notices(t.fw).length; + await callTool(t.fw.framework, 'arc--status', {}); + await waitFor(() => notices(t.fw).length >= before + 2, 'both changes told'); + const [first, second] = notices(t.fw).slice(before); + assert.match(first!, /now unavailable: peek/, 'the result\'s change first'); + assert.match(second!, /peek\(\)/, 'then the later one'); + } finally { await t.done(); } +}); + +test('look shows a suggestion its re-read carries', async () => { + let suggest = false; + const t = await setup((msg, peer, state) => { + if (msg.method !== 'toolsets/list' || !suggest) return false; + peer.reply(msg.id, { open: state.open, _meta: { 'mcpl/hints': { rev: state.next(), tools: state.statement, suggest: [{ toolset: 'game', action: 'open', reason: 'a rematch is ready' }] } } }); + return true; + }); + try { + suggest = true; + assert.match(await look(t.fw), /arc suggests opening game: a rematch is ready/); + } finally { await t.done(); } +}); + +test('a hint naming a tool tools/list hasn\'t listed is recovered from tools/list, then toolsets/list', async () => { + const t = await setup(); + try { + const from = t.server.received.length; + t.server.peer.notify('tools/hints', t.state.hint([{ name: 'lantern', visible: true }])); + await waitFor(() => t.server.received.slice(from).some((m) => m.method === 'toolsets/list'), 'recovery read toolsets/list'); + const recovery = t.server.received.slice(from).map((m) => m.method).filter((m) => m === 'tools/list' || m === 'toolsets/list'); + assert.deepEqual(recovery, ['tools/list', 'toolsets/list']); + } finally { await t.done(); } +}); + +test('a lost move is repaired by the recovery\'s tools/list, and the model\'s tool list follows it without a further list change', async () => { + const tools: Json[] = TOOLS.map((x) => ({ ...x })); + const t = await setup(() => false, {}, ADVERT, tools); + const listed = () => t.fw.framework.getAllTools().map((x) => x.name); + const told = (text: string) => notices(t.fw).filter((x) => x.includes(text)).length; + const actOnPeek = async () => resultText(await callTool(t.fw.framework, 'toolsets--act', { tool: 'peek', args: {} })); + try { + assert.ok(!listed().includes('arc--peek'), 'peek starts dynamic'); + present(tools, 'peek', 'static'); + t.server.peer.notify('tools/hints', t.state.hint([{ name: 'peek', presence: 'static' }])); + await roundTrip(t); + assert.ok(!listed().includes('arc--peek'), 'the move waits for the next tool list'); + t.server.peer.notify('notifications/tools/list_changed', {}); + await waitFor(() => listed().includes('arc--peek'), 'peek moved into the tool list'); + assert.equal(told('peek is in your tool list now, as arc--peek.'), 1, 'told once, with where it went'); + present(tools, 'peek', 'dynamic'); + const peek = tools.find((x) => x.name === 'peek')!; + peek._meta = { ...(peek._meta as Json), 'mcpl/visible': true }; + t.state.next(); + const lists = t.methods().filter((m) => m === 'toolsets/list').length; + t.server.peer.notify('tools/hints', t.state.hint([])); + await waitFor(() => !listed().includes('arc--peek'), 'a skipped rev recovered, and the tool list rebuilt from it without peek'); + assert.doesNotMatch(await actOnPeek(), /is not a dynamic tool/); + assert.equal(told('peek is in your tool list now'), 1, 'the first move was applied once'); + assert.equal(told('peek left your tool list'), 1, 'and the move back told once'); + assert.ok(notices(t.fw).some((x) => x.includes('peek() — Peek around.')), 'and announced, as the list made it visible'); + } finally { await t.done(); } +}); + +test('an entry that moves a tool to dynamic keeps the definition it carries', async () => { + const tools: Json[] = [...TOOLS.map((x) => ({ ...x })), { name: 'echo', description: 'Echo as listed.', inputSchema: { type: 'object', properties: {} } }]; + const t = await setup(() => false, {}, ADVERT, tools); + try { + present(tools, 'echo', 'dynamic'); + t.server.peer.notify('tools/hints', t.state.hint([{ name: 'echo', presence: 'dynamic', visible: true, definition: { description: 'Echo, as hinted now.', inputSchema: { type: 'object', properties: { loud: { type: 'boolean' } } } } }])); + t.server.peer.notify('notifications/tools/list_changed', {}); + await waitFor(() => !t.fw.framework.getAllTools().some((x) => x.name === 'arc--echo'), 'echo moved out of the tool list'); + const refused = await callTool(t.fw.framework, 'toolsets--act', { tool: 'echo', args: { loud: 'yes' } }); + assert.ok(refused.isError, 'checked against the definition the hint carried'); + assert.match(resultText(refused), /loud/); + } finally { await t.done(); } +}); + +test('reminders restate only servers whose changes may be told unasked', () => { + const told = (canNotify: boolean) => { + const deps = { notify: () => true, canNotify: () => canNotify, refreshTools: () => {}, log: () => {}, listedName: (_s: string, tool: string) => `arc--${tool}` }; + const hub = new ToolsetHub(deps); + const c = hub.controllerFor({ id: 'arc', toolHints: true } as McplServerConfig); + c.presence.beginConnection({ toolHints: true, toolsets: { build: { title: 'Building' } } }, true); + c.presence.setTools([{ name: 'place', description: 'Place a block.', inputSchema: { type: 'object' }, _meta: { 'mcpl/toolset': 'build' } }]); + c.presence.noteOpened('build', undefined, 'Workshop'); + hub.observeCompile('scout', [{ role: 'user', content: [{ type: 'text', text: '[arc/build] Workshop tools now' }] }]); + hub.observeCompile('scout', [{ role: 'user', content: [{ type: 'text', text: 'later' }] }]); + return hub.reminderFor('scout'); + }; + assert.match(told(true) ?? '', /\[arc\/build\] Workshop/); + assert.equal(told(false), null); +}); + +test('a first sentence is cut by characters, never inside a surrogate pair', () => { + const cut = firstSentence(`${'a'.repeat(138)}😀😀 and more`); + assert.equal([...cut].length, 140); + assert.ok(cut.endsWith('…')); + assert.doesNotMatch(cut, /[\uD800-\uDBFF](?![\uDC00-\uDFFF])/, 'no lone high surrogate'); +}); + +test('a group the host opened that the manifest then declares, and later drops, isn\'t open', () => { + const p = new ToolPresence('arc', META_TOOL_NAMES); + p.beginConnection({ toolHints: true }, true); + p.setTools([{ name: 'dig', description: 'Dig.', inputSchema: { type: 'object' }, _meta: { 'mcpl/toolset': 'cave' } }]); + p.openGroup('cave'); + p.setManifest({ toolHints: true, toolsets: { cave: { title: 'Cave' } } }); + p.setManifest({ toolHints: true }); + assert.equal(p.isOpen('cave'), false); +}); + +test('a server that didn\'t advertise toolHints: its hints are ignored and every tool stays static', async () => { + const t = await setup((msg, peer) => { + if (msg.method !== 'tools/call') return false; + peer.reply(msg.id, { content: [{ type: 'text', text: 'ok' }], _meta: { 'mcpl/hints': { rev: 1, tools: [{ name: 'status', presence: 'dynamic' }] } } }); + return true; + }, {}, { ...ADVERT, toolHints: undefined }); + const names = () => t.fw.framework.getAllTools().map((x) => x.name); + try { + for (const tool of ['status', 'play', 'place', 'dig', 'peek']) assert.ok(names().includes(`arc--${tool}`), `arc--${tool} is in the tool list`); + t.server.peer.notify('tools/hints', { rev: 2, tools: [{ name: 'play', presence: 'dynamic' }] }); + await roundTrip(t); + t.server.peer.notify('notifications/tools/list_changed', {}); + await waitFor(() => t.methods().filter((m) => m === 'tools/list').length >= 2, 'the tool list read again'); + await roundTrip(t); + for (const tool of ['status', 'play', 'peek']) assert.ok(names().includes(`arc--${tool}`), `arc--${tool} is still in the tool list`); + assert.ok(!t.methods().includes('toolsets/list'), 'no toolsets/list'); + assert.equal(notices(t.fw).length, 0, 'nothing told'); + } finally { await t.done(); } +}); + +test('revs start over on each connection: after a reconnect, the new connection\'s next rev applies', async () => { + const t = await setup(() => false, { reconnect: true, reconnectIntervalMs: 50 }); + try { + t.server.peer.notify('tools/hints', t.state.hint([{ name: 'peek', visible: true }])); + await waitFor(() => notices(t.fw).some((x) => x.includes('peek()')), 'peek announced'); + const lists = t.methods().filter((m) => m === 'toolsets/list').length; + t.state.rev = 0; + t.server.peer.ws.terminate(); + await waitFor(() => t.methods().filter((m) => m === 'toolsets/list').length > lists, 'toolsets/list after the reconnect'); + await roundTrip(t); + const from = t.methods().length; + const before = notices(t.fw).length; + t.server.peer.notify('tools/hints', t.state.hint([{ name: 'peek', visible: false }])); + await waitFor(() => notices(t.fw).slice(before).some((x) => x.includes('now unavailable: peek')), 'the new connection\'s change told'); + await settle(400); + assert.ok(!t.methods().slice(from).some((m) => m === 'tools/list' || m === 'toolsets/list'), 'no recovery'); + } finally { await t.done(); } +}); + +test('a tools/list whose rev is past the last one read recovers what was lost', async () => { + const t = await setup(); + try { + t.state.hint([{ name: 'peek', visible: true }]); + const from = t.methods().length; + t.server.peer.notify('notifications/tools/list_changed', {}); + await waitFor(() => notices(t.fw).some((x) => x.includes('peek()')), 'the change never sent recovered, and told'); + assert.ok(t.methods().slice(from).includes('toolsets/list'), 'recovered through toolsets/list'); + } finally { await t.done(); } +}); + +test('vector 14: an open and a retitle made on another connection arrive as hint toolsets entries', async () => { + const t = await setup(); + try { + t.state.open = [{ toolset: 'build', title: 'Shared workshop' }]; + t.server.peer.notify('tools/hints', t.state.hint([{ name: 'place', visible: true }], { toolsets: [{ toolset: 'build', state: 'open', title: 'Shared workshop' }] })); + await waitFor(() => notices(t.fw).some((x) => x.includes('[arc/build] Shared workshop') && x.includes('place(')), 'the open told'); + const act = await callTool(t.fw.framework, 'toolsets--act', { tool: 'place', args: { x: 1 } }); + assert.ok(!act.isError, resultText(act)); + t.state.open = [{ toolset: 'build', title: 'Night shift' }]; + t.server.peer.notify('tools/hints', t.state.hint([], { toolsets: [{ toolset: 'build', state: 'open', title: 'Night shift' }, { toolset: 'game', state: 'open', title: 'No instance' }] })); + await waitFor(() => notices(t.fw).some((x) => x.includes('[arc/build] Night shift')), 'the retitle told'); + assert.ok(!notices(t.fw).some((x) => x.includes('[arc/game')), 'an entry naming no instance of a toolset with instances opens nothing'); + } finally { await t.done(); } +}); + +test('an instance the host believed open, missing from the next statement with no ended record, is told closed', async () => { + const t = await setup((msg, peer, state) => { + if (msg.method !== 'toolsets/open') return false; + state.open = [{ toolset: 'game', instance: 'g1', title: 'G1' }]; + peer.reply(msg.id, { toolset: 'game', instance: 'g1', title: 'G1', _meta: { 'mcpl/hints': state.hint([{ name: 'play', instance: 'g1', visible: true }]) } }); + return true; + }); + try { + await callTool(t.fw.framework, 'toolsets--open', { server: 'arc', toolset: 'game', instance: 'g1' }); + t.state.open = []; + t.state.statement = t.state.statement.filter((x) => x.instance !== 'g1'); + assert.match(await look(t.fw), /\[arc\/game:g1\] is closed: arc no longer has it open\./); + } finally { await t.done(); } +}); + +function tappedController(writes: () => boolean = () => true, tools: Json[] = TOOLS, requests: Record void) => unknown> = {}) { + const told: string[] = []; + const hub = new ToolsetHub({ + notify: (_s: string, text: string) => { if (!writes()) return false; told.push(text); return true; }, + canNotify: () => true, refreshTools: () => {}, log: () => {}, listedName: (_s: string, tool: string) => `arc--${tool}`, + }); + const c = hub.controllerFor({ id: 'arc', toolHints: true } as McplServerConfig); + let tap: (read: unknown, epoch: number) => void = () => {}; + const connection = () => ({ + setReadTap: (f: typeof tap | null) => { if (f) tap = f; }, transportEpoch: 0, isConnected: true, toolsetDeclarations: ADVERT, + sendToolsList: async () => requests.toolsList?.(read), + sendToolsetsList: async () => requests.toolsetsList?.(read), + }); + const attached = connection(); + c.attach(attached as never); + const read = (r: unknown) => tap(r, 0); + read({ kind: 'result', method: 'tools/list', params: {}, result: { tools }, awaited: true }); + const callResult = (rev: number, tools: Json[]) => ({ content: [{ type: 'text', text: 'ok' }], _meta: { 'mcpl/hints': { rev, tools } } }); + const readCall = (result: Json) => read({ kind: 'result', method: 'tools/call', params: { name: 'status' }, result, awaited: true }); + const reattach = () => { + c.detach(attached as never); + c.attach(connection() as never); + read({ kind: 'result', method: 'tools/list', params: {}, result: { tools }, awaited: true }); + }; + return { hub, c, told, read, callResult, readCall, reattach }; +} + +test('a result\'s change told as a notice before its caller takes it isn\'t shown again with the result', () => { + const { c, told, read, callResult, readCall } = tappedController(); + const result = callResult(1, [{ name: 'peek', visible: true }]); + readCall(result); + read({ kind: 'hints', params: { rev: 2, tools: [{ name: 'peek', visible: false }] } }); + assert.equal(told.length, 2, 'the result\'s change, then the later one'); + assert.match(told[0]!, /peek\(\)/); + assert.match(told[1]!, /now unavailable: peek/); + assert.equal(c.takeChangeText(result), '', 'not shown again with the result'); +}); + +test('a result\'s change drained while no notice can be written is dropped, not shown later with its result', () => { + const { c, told, read, callResult, readCall } = tappedController(() => false); + const result = callResult(1, [{ name: 'peek', visible: true }]); + readCall(result); + read({ kind: 'hints', params: { rev: 2, tools: [] } }); + assert.equal(told.length, 0); + assert.equal(c.takeChangeText(result), ''); +}); + +test('taking a result\'s change first tells, as a notice, the change of a result read before it', () => { + const { c, told, callResult, readCall } = tappedController(); + readCall(callResult(1, [{ name: 'peek', visible: true }])); + const later = callResult(2, [{ name: 'peek', visible: false }]); + readCall(later); + const shown = c.takeChangeText(later); + assert.equal(told.length, 1, 'the earlier change, told first'); + assert.match(told[0]!, /peek\(\)/); + assert.match(shown, /now unavailable: peek/); +}); + +test('an ended record two statements report before either is told is told once', async () => { + const { told, read } = tappedController(); + const statement = (rev: number) => ({ open: [], ended: [{ id: 'e1', toolset: 'game', instance: 'g1', reason: 'the game is over' }], _meta: { 'mcpl/hints': { rev, tools: [] } } }); + read({ kind: 'result', method: 'toolsets/list', params: {}, result: statement(1), awaited: true }); + read({ kind: 'result', method: 'toolsets/list', params: {}, result: statement(2), awaited: true }); + await nextMacrotask(); + assert.equal(told.join('\n').split('[arc/game:g1] ended: the game is over.').length - 1, 1); +}); + +test('a hint read right after a tools/list\'s last page, in the same chunk, isn\'t undone by that list', async () => { + let held: Json | null = null; + let holding = false; + const t = await setup((msg) => { + if (msg.method !== 'tools/list' || !holding) return false; + held = msg; + return true; + }); + try { + holding = true; + const before = notices(t.fw).length; + t.server.peer.notify('notifications/tools/list_changed', {}); + await waitFor(() => held !== null, 'the tool list asked for'); + const connection = (t.fw.framework as unknown as { mcplServerRegistry: { getServer(id: string): unknown } }).mcplServerRegistry.getServer('arc'); + const transport = (connection as { transport: { emit(event: string, line: string): void } }).transport; + transport.emit('line', JSON.stringify({ jsonrpc: '2.0', id: (held as unknown as Json).id, result: { tools: TOOLS, _meta: { 'mcpl/rev': t.state.rev } } })); + transport.emit('line', JSON.stringify({ jsonrpc: '2.0', method: 'tools/hints', params: t.state.hint([{ name: 'peek', visible: true }]) })); + await roundTrip(t); + const told = notices(t.fw).slice(before); + assert.ok(told.some((x) => x.includes('peek()')), 'the hint\'s change told'); + assert.ok(!told.some((x) => x.includes('now unavailable: peek')), 'and not undone by the list read before it'); + } finally { await t.done(); } +}); + +test('a move a hint announces but the next tools/list contradicts never reaches the tool list', async () => { + const t = await setup(); + const listed = () => t.fw.framework.getAllTools().map((x) => x.name); + try { + t.server.peer.notify('tools/hints', t.state.hint([{ name: 'peek', presence: 'static', visible: true }])); + await waitFor(() => notices(t.fw).some((x) => x.includes('peek()')), 'the hint read'); + const lists = t.methods().filter((m) => m === 'tools/list').length; + t.server.peer.notify('notifications/tools/list_changed', {}); + await waitFor(() => t.methods().filter((m) => m === 'tools/list').length > lists, 'the tool list read again'); + await roundTrip(t); + assert.ok(!listed().includes('arc--peek'), 'peek stays where the list says'); + assert.ok(!notices(t.fw).some((x) => x.includes('peek is in your tool list now'))); + } finally { await t.done(); } +}); + +test('a move a hint announces but a reconnect\'s tools/list contradicts never reaches the tool list', async () => { + const t = await setup(() => false, { reconnect: true, reconnectIntervalMs: 50 }); + const listed = () => t.fw.framework.getAllTools().map((x) => x.name); + try { + t.server.peer.notify('tools/hints', t.state.hint([{ name: 'peek', presence: 'static', visible: true }])); + await waitFor(() => notices(t.fw).some((x) => x.includes('peek()')), 'the hint read'); + const lists = t.methods().filter((m) => m === 'toolsets/list').length; + t.state.rev = 0; + t.server.peer.ws.terminate(); + await waitFor(() => t.methods().filter((m) => m === 'toolsets/list').length > lists, 'toolsets/list after the reconnect'); + await roundTrip(t); + t.server.peer.notify('notifications/tools/list_changed', {}); + const after = t.methods().filter((m) => m === 'tools/list').length; + await waitFor(() => t.methods().filter((m) => m === 'tools/list').length > after, 'the tool list read again'); + await roundTrip(t); + assert.ok(!listed().includes('arc--peek')); + assert.ok(!notices(t.fw).some((x) => x.includes('peek is in your tool list now'))); + } finally { await t.done(); } +}); + +test('a toolsets/list naming a tool tools/list hasn\'t listed is recovered from, once', async () => { + const t = await setup(); + try { + t.state.statement.push({ name: 'lantern', visible: true }); + const from = t.server.received.length; + await look(t.fw); + await waitFor(() => t.server.received.slice(from).filter((m) => m.method === 'toolsets/list').length >= 2, 'the recovery read toolsets/list'); + await roundTrip(t); + await settle(400); + const after = t.server.received.slice(from).map((m) => m.method).filter((m) => m === 'tools/list' || m === 'toolsets/list'); + assert.deepEqual(after, ['toolsets/list', 'tools/list', 'toolsets/list'], 'look\'s read, then one recovery: its own toolsets/list, naming lantern again, starts no other'); + } finally { await t.done(); } +}); + +test('a recovery whose tools/list fails reads no toolsets/list', async () => { + let failing = false; + const t = await setup((msg, peer) => { + if (msg.method !== 'tools/list' || !failing) return false; + peer.error(msg.id, -32000, 'list unavailable'); + return true; + }); + try { + failing = true; + const from = t.server.received.length; + t.state.next(); + t.server.peer.notify('tools/hints', t.state.hint([])); + await waitFor(() => t.server.received.slice(from).some((m) => m.method === 'tools/list'), 'the recovery read tools/list'); + await roundTrip(t); + await settle(400); + assert.ok(!t.server.received.slice(from).some((m) => m.method === 'toolsets/list')); + } finally { await t.done(); } +}); + +test('a tools/list whose answer comes after its timeout applies nothing', async () => { + let held: Json | null = null; + let holding = false; + const t = await setup((msg) => { + if (msg.method !== 'tools/list' || !holding) return false; + held = msg; + return true; + }, { requestTimeoutMs: 300 }); + try { + holding = true; + const before = notices(t.fw).length; + t.server.peer.notify('notifications/tools/list_changed', {}); + await waitFor(() => held !== null, 'the tool list asked for'); + await settle(500); + const tools = TOOLS.map((x) => (x.name === 'peek' ? { ...x, description: 'A late description.', _meta: { 'mcpl/presence': 'dynamic', 'mcpl/visible': true } } : x)); + t.server.peer.reply((held as unknown as Json).id, { tools, _meta: { 'mcpl/rev': t.state.rev } }); + await roundTrip(t); + assert.ok(!notices(t.fw).slice(before).some((x) => x.includes('peek()'))); + assert.doesNotMatch(await look(t.fw, { server: 'arc', tool: 'peek' }), /A late description/); + } finally { await t.done(); } +}); + +test('two paged tools/lists read at once each apply whole, never mixed', async () => { + const pages: Array<{ msg: Json; peer: ScriptedPeer }> = []; + let paging = false; + const t = await setup((msg, peer) => { + if (msg.method !== 'tools/list' || !paging) return false; + pages.push({ msg, peer }); + return true; + }); + const listed = () => t.fw.framework.getAllTools().map((x) => x.name); + try { + paging = true; + const before = notices(t.fw).length; + t.server.peer.notify('notifications/tools/list_changed', {}); + t.state.next(); + t.server.peer.notify('tools/hints', t.state.hint([])); + await waitFor(() => pages.length >= 2, 'two lists asked for'); + const answer = (i: number, result: Json) => pages[i]!.peer.reply(pages[i]!.msg.id, result); + const rev = t.state.rev; + const peekFrom = (list: string, visible: boolean) => ({ name: 'peek', description: `From list ${list}.`, inputSchema: { type: 'object', properties: {} }, _meta: { 'mcpl/presence': 'dynamic', 'mcpl/visible': visible } }); + answer(0, { tools: TOOLS.slice(0, 3), nextCursor: 'a2', _meta: { 'mcpl/rev': rev } }); + answer(1, { tools: [...TOOLS.slice(0, 4), peekFrom('B', false)], _meta: { 'mcpl/rev': rev } }); + await waitFor(() => pages.length >= 3, 'the first list\'s second page asked for'); + answer(2, { tools: [TOOLS[3]!, peekFrom('A', true)], _meta: { 'mcpl/rev': rev } }); + await waitFor(() => notices(t.fw).slice(before).some((x) => x.includes('peek() — From list A.')), 'the first list applied whole, its second page included'); + assert.ok(listed().includes('arc--status'), 'its first page included too'); + assert.match(await look(t.fw, { server: 'arc', tool: 'peek' }), /From list A\./); + } finally { await t.done(); } +}); + +test('a tools/list whose pages carry different revs starts a recovery, after which the later change stands', async () => { + const tools: Json[] = TOOLS.map((x) => ({ ...x })); + let paging = false; + let second: { msg: Json; peer: ScriptedPeer } | null = null; + const t = await setup((msg, peer, state) => { + if (msg.method !== 'tools/list' || !paging) return false; + if ((msg.params as Json | undefined)?.cursor === 'p2') second = { msg, peer }; + else peer.reply(msg.id, { tools: tools.slice(3), nextCursor: 'p2', _meta: { 'mcpl/rev': state.rev } }); + return true; + }, {}, ADVERT, tools); + try { + paging = true; + const from = t.server.received.length; + const before = notices(t.fw).length; + t.server.peer.notify('notifications/tools/list_changed', {}); + await waitFor(() => second !== null, 'the second page asked for'); + const peek = tools.find((x) => x.name === 'peek')!; + peek._meta = { ...(peek._meta as Json), 'mcpl/visible': true }; + t.server.peer.notify('tools/hints', t.state.hint([{ name: 'peek', visible: true }])); + const held = second as unknown as { msg: Json; peer: ScriptedPeer }; + paging = false; + held.peer.reply(held.msg.id, { tools: tools.slice(0, 3), _meta: { 'mcpl/rev': t.state.rev } }); + await waitFor(() => t.server.received.slice(from).some((m) => m.method === 'toolsets/list'), 'a recovery read toolsets/list'); + await roundTrip(t); + const peekNotices = notices(t.fw).slice(before).filter((x) => x.includes('peek')); + assert.match(peekNotices[peekNotices.length - 1]!, /peek\(\)/, 'peek is visible once the recovery has read the list again'); + } finally { await t.done(); } +}); + +test('without the pushEvents grant, an announcement that left the context isn\'t restated by a reminder', async () => { + const t = await setup((msg, peer, state) => { + if (msg.method !== 'toolsets/open') return false; + state.open = [{ toolset: 'build', title: 'Workshop' }]; + peer.reply(msg.id, { toolset: 'build', title: 'Workshop', _meta: { 'mcpl/hints': state.hint([{ name: 'place', visible: true }]) } }); + return true; + }, { disabledCapabilities: ['pushEvents'] }); + const { createMockResponse } = await import('./helpers/mock-membrane.js'); + const turn = async (scripted = false) => { + const n = t.fw.membrane.calls.length; + if (!scripted) t.fw.membrane.pushResponse(createMockResponse([{ type: 'text', text: 'ok' }])); + t.fw.framework.pushEvent({ type: 'external-message', source: 'test', content: [{ type: 'text', text: 'go' }], metadata: {}, triggerInference: true } as never); + await waitFor(() => t.fw.membrane.calls.length > n && t.fw.framework.getAgent('scout')!.state.status === 'idle', 'the turn\'s request made and the turn ended'); + return JSON.stringify(t.fw.membrane.calls[n]!.messages); + }; + try { + t.fw.membrane.pushResponse(createMockResponse([{ type: 'tool_use', id: 'toolu_open', name: 'toolsets--open', input: { server: 'arc', toolset: 'build' } }] as never, 'tool_use')); + t.fw.membrane.pushResponse(createMockResponse([{ type: 'text', text: 'opened' }])); + await turn(true); + await waitFor(() => windowText(t.fw.framework).some((x) => x.includes('[arc/build] Workshop')), 'the open stored'); + assert.match(await turn(), /\[arc\/build\]/, 'the announcement is in view'); + const cm = t.fw.framework.getAgent('scout')!.getContextManager(); + for (const m of cm.getAllMessages()) { + if (JSON.stringify(m.content).includes('toolu_open')) cm.removeMessage(m.id); + } + const later = (await turn()) + (await turn()); + assert.doesNotMatch(later, /Open toolsets \(a reminder/); + } finally { await t.done(); } +}); + +test('a hint or statement naming a tool the operator\'s tool policy leaves out starts no recovery', async () => { + const t = await setup(() => false, { disabledTools: ['peek', 'lantern'] }); + try { + const from = t.server.received.length; + t.server.peer.notify('tools/hints', t.state.hint([{ name: 'peek', visible: true }, { name: 'lantern', visible: true }])); + await roundTrip(t); + t.state.statement.push({ name: 'lantern', visible: true }); + await look(t.fw); + await settle(400); + assert.ok(!t.server.received.slice(from).some((m) => m.method === 'tools/list')); + assert.equal(t.server.received.slice(from).filter((m) => m.method === 'toolsets/list').length, 1, 'only look\'s own read'); + } finally { await t.done(); } +}); + +test('look with a tool or toolset, or one that fails, tells its re-read\'s change as a notice', async () => { + const t = await setup(); + try { + const looks = [{ server: 'arc', tool: 'status' }, { server: 'arc', toolset: 'build' }, { server: 'arc', toolset: 'nope' }]; + for (const [i, input] of looks.entries()) { + const before = notices(t.fw).length; + const visible = i % 2 === 0; + t.state.statement = visible ? [{ name: 'dig', visible: true }, { name: 'peek', visible: true }] : [{ name: 'dig', visible: true }]; + await look(t.fw, input); + await waitFor(() => notices(t.fw).slice(before).some((x) => x.includes(visible ? 'peek()' : 'now unavailable: peek')), `the re-read change of look ${JSON.stringify(input)} told`); + } + } finally { await t.done(); } +}); + +test('a definition first described in a notice that wasn\'t written is described again', () => { + let writing = false; + const { told, read } = tappedController(() => writing); + read({ kind: 'hints', params: { rev: 1, tools: [{ name: 'peek', visible: true }] } }); + read({ kind: 'hints', params: { rev: 2, tools: [{ name: 'peek', visible: false }] } }); + writing = true; + read({ kind: 'hints', params: { rev: 3, tools: [{ name: 'peek', visible: true }] } }); + assert.equal(told.length, 1); + assert.match(told[0]!, /peek\(\) — Peek around\./); +}); + +test('a tool listed twice is known by its first listing, which act checks arguments against', () => { + const tools = [...TOOLS.filter((t) => t.name !== 'peek'), + { name: 'peek', description: 'The first listing.', inputSchema: { type: 'object', properties: { far: { type: 'boolean' } } }, _meta: { 'mcpl/presence': 'dynamic', 'mcpl/visible': false } }, + { name: 'peek', description: 'The second listing.', inputSchema: { type: 'object', properties: { far: { type: 'string' } } }, _meta: { 'mcpl/presence': 'dynamic', 'mcpl/visible': false } }]; + const { hub, told, read } = tappedController(() => true, tools); + read({ kind: 'hints', params: { rev: 1, tools: [{ name: 'peek', visible: true }] } }); + assert.match(told.join('\n'), /peek\(far\?: boolean\) — The first listing\./); + const target = resolveAct(hub, { tool: 'peek', args: { far: 'yes' } }); + assert.ok(!target.ok, 'checked against the first listing\'s schema'); +}); + +test('taking a change doesn\'t count its ended record as told: only told() does', async () => { + const { c, told, read } = tappedController(); + const statement = (rev: number) => ({ open: [], ended: [{ id: 'e1', toolset: 'game', instance: 'g1', reason: 'the game is over' }], _meta: { 'mcpl/hints': { rev, tools: [] } } }); + const first = statement(1); + read({ kind: 'result', method: 'toolsets/list', params: {}, result: first, awaited: true }); + c.takeChange(first); + read({ kind: 'result', method: 'toolsets/list', params: {}, result: statement(2), awaited: true }); + await nextMacrotask(); + assert.match(told.join('\n'), /\[arc\/game:g1\] ended: the game is over\./, 'still owed after a take with no told'); +}); + +test('a push\'s change that nothing takes is told as a notice', async () => { + const { told, read } = tappedController(); + read({ kind: 'push', params: { featureSet: 'arc.events', eventId: 'p1', payload: { content: [] }, hints: { rev: 1, tools: [{ name: 'peek', visible: true }] } } }); + await nextMacrotask(); + assert.match(told.join('\n'), /peek\(\)/); +}); + +test('an ended record told before the server\'s connection was let go isn\'t told again on the next one', async () => { + const { told, read, reattach } = tappedController(); + const statement = (rev: number) => ({ open: [], ended: [{ id: 'e1', toolset: 'game', instance: 'g1', reason: 'the game is over' }], _meta: { 'mcpl/hints': { rev, tools: [] } } }); + read({ kind: 'result', method: 'toolsets/list', params: {}, result: statement(1), awaited: true }); + await nextMacrotask(); + reattach(); + read({ kind: 'result', method: 'toolsets/list', params: {}, result: statement(1), awaited: true }); + await nextMacrotask(); + assert.equal(told.join('\n').split('ended: the game is over.').length - 1, 1); +}); + +test('close with nothing named, after a group the host opened was declared, says nothing is open', async () => { + const { hub, c } = tappedController(); + c.setGroupOpen('cave', true); + c.manifestChanged({ toolHints: true, toolsets: { cave: { title: 'Cave' } } }); + const result = await new ToolsetsModule(hub).handleToolCall({ id: 'c1', name: 'close', input: { server: 'arc' } } as never); + assert.ok(result.isError); + assert.match(String(result.error), /nothing is open on arc/); +}); + +test('a tool a recovery\'s list puts in a toolset leaves the model\'s tool list without a further list change', async () => { + const tools: Json[] = TOOLS.map((x) => ({ ...x })); + const t = await setup(() => false, {}, ADVERT, tools); + const listed = () => t.fw.framework.getAllTools().map((x) => x.name); + try { + tools.find((x) => x.name === 'status')!._meta = { 'mcpl/toolset': 'build' }; + t.state.next(); + t.server.peer.notify('tools/hints', t.state.hint([])); + await waitFor(() => !listed().includes('arc--status'), 'the tool list rebuilt from the recovery\'s list'); + assert.ok(notices(t.fw).some((x) => x.includes('status left your tool list'))); + } finally { await t.done(); } +}); + +test('after a reconnect whose tools/list moved a tool, the model\'s tool list agrees with presence', async () => { + const tools: Json[] = TOOLS.map((x) => ({ ...x })); + const t = await setup(() => false, { reconnect: true, reconnectIntervalMs: 50 }, ADVERT, tools); + const listed = () => t.fw.framework.getAllTools().map((x) => x.name); + try { + present(tools, 'peek', 'static'); + const lists = t.methods().filter((m) => m === 'toolsets/list').length; + t.state.rev = 0; + t.server.peer.ws.terminate(); + await waitFor(() => t.methods().filter((m) => m === 'toolsets/list').length > lists, 'toolsets/list after the reconnect'); + await waitFor(() => listed().includes('arc--peek'), 'peek in the tool list'); + assert.match(resultText(await callTool(t.fw.framework, 'toolsets--act', { tool: 'peek', args: {} })), /peek is not a dynamic tool/); + } finally { await t.done(); } +}); + +test('a recovery\'s tools/list change is told as it is read, before a hint read after it', async () => { + const statement = { open: [], _meta: { 'mcpl/hints': { rev: 4, tools: [] } } }; + const { told, read } = tappedController(() => true, TOOLS, { + toolsList: (r) => { + const tools = TOOLS.map((x) => (x.name === 'peek' ? { ...x, _meta: { 'mcpl/presence': 'dynamic', 'mcpl/visible': true } } : x)); + r({ kind: 'result', method: 'tools/list', params: {}, result: { tools, _meta: { 'mcpl/rev': 2 } }, awaited: true }); + r({ kind: 'hints', params: { rev: 3, tools: [{ name: 'peek', visible: false }] } }); + return { tools }; + }, + toolsetsList: (r) => { + r({ kind: 'result', method: 'toolsets/list', params: {}, result: statement, awaited: true }); + return statement; + }, + }); + read({ kind: 'hints', params: { rev: 2, tools: [] } }); + await waitFor(() => told.some((x) => x.includes('now unavailable: peek')), 'the recovery ran', 2000); + const shown = told.findIndex((x) => x.includes('peek()')); + const gone = told.findIndex((x) => x.includes('now unavailable: peek')); + assert.ok(shown >= 0 && shown < gone, 'the list\'s change first, then the hint\'s'); +}); + +test('a change look hands back is told before a change read after it', async () => { + const statement = { open: [], _meta: { 'mcpl/hints': { rev: 1, tools: [{ name: 'peek', visible: true }] } } }; + const { c, told, read, callResult, readCall } = tappedController(() => true, TOOLS, { + toolsetsList: (r) => { + r({ kind: 'result', method: 'toolsets/list', params: {}, result: statement, awaited: true }); + return statement; + }, + }); + const cs = await c.refreshOpen(); + readCall(callResult(2, [{ name: 'peek', visible: false }])); + c.tell(cs!); + await nextMacrotask(); + const shown = told.findIndex((x) => x.includes('peek()')); + const gone = told.findIndex((x) => x.includes('now unavailable: peek')); + assert.ok(shown >= 0 && shown < gone, 'look\'s change first'); +}); + +test('an ended record that expires while its notes wait is still told once', async () => { + const { told, read } = tappedController(); + const statement = (rev: number, ended: Json[]) => ({ open: [], ended, _meta: { 'mcpl/hints': { rev, tools: [] } } }); + const e1 = [{ id: 'e1', toolset: 'game', instance: 'g1', reason: 'the game is over' }]; + read({ kind: 'result', method: 'toolsets/list', params: {}, result: statement(1, e1), awaited: true }); + read({ kind: 'result', method: 'toolsets/list', params: {}, result: statement(2, e1), awaited: true }); + read({ kind: 'result', method: 'toolsets/list', params: {}, result: statement(3, []), awaited: true }); + await nextMacrotask(); + assert.equal(told.join('\n').split('ended: the game is over.').length - 1, 1); +}); + +test('an instance reopened under an ended instance\'s name is told closed when it goes', async () => { + const { told, read } = tappedController(); + const statement = (rev: number, open: Json[], ended: Json[]) => ({ open, ended, _meta: { 'mcpl/hints': { rev, tools: [] } } }); + const e1 = [{ id: 'e1', toolset: 'game', instance: 'g1', reason: 'the game is over' }]; + const g1 = [{ toolset: 'game', instance: 'g1', title: 'G1' }]; + read({ kind: 'result', method: 'toolsets/list', params: {}, result: statement(1, g1, []), awaited: true }); + read({ kind: 'result', method: 'toolsets/list', params: {}, result: statement(2, [], e1), awaited: true }); + await nextMacrotask(); + read({ kind: 'result', method: 'toolsets/list', params: {}, result: statement(3, g1, e1), awaited: true }); + read({ kind: 'result', method: 'toolsets/list', params: {}, result: statement(4, [], e1), awaited: true }); + await nextMacrotask(); + assert.match(told.join('\n'), /\[arc\/game:g1\] is closed/); +}); + +test('a paged tools/list whose rev only some pages carry isn\'t taken for one that straddled a change', async () => { + let paging = false; + const t = await setup((msg, peer, state) => { + if (msg.method !== 'tools/list' || !paging) return false; + if ((msg.params as Json | undefined)?.cursor === 'p2') peer.reply(msg.id, { tools: TOOLS.slice(3), _meta: { 'mcpl/rev': state.rev } }); + else peer.reply(msg.id, { tools: TOOLS.slice(0, 3), nextCursor: 'p2' }); + return true; + }); + try { + paging = true; + const from = t.server.received.length; + t.server.peer.notify('notifications/tools/list_changed', {}); + await waitFor(() => t.server.received.slice(from).filter((m) => m.method === 'tools/list').length >= 2, 'both pages asked for'); + await roundTrip(t); + await settle(400); + assert.ok(!t.server.received.slice(from).some((m) => m.method === 'toolsets/list'), 'no recovery'); + } finally { await t.done(); } +}); + +test('a notice describes a definition it shows twice only once', async () => { + const { told, read } = tappedController(); + const statement = { open: [{ toolset: 'game', instance: 'g1', title: 'G1' }, { toolset: 'game', instance: 'g2', title: 'G2' }], _meta: { 'mcpl/hints': { rev: 1, tools: [] } } }; + read({ kind: 'result', method: 'toolsets/list', params: {}, result: statement, awaited: true }); + await nextMacrotask(); + read({ kind: 'hints', params: { rev: 2, tools: [{ name: 'play', instance: 'g1', visible: true }, { name: 'play', instance: 'g2', visible: true }] } }); + const text = told.join('\n'); + assert.match(text, /\[arc\/game:g1\]/, 'both instances are announced'); + assert.match(text, /\[arc\/game:g2\]/, 'both instances are announced'); + assert.equal(text.split('play(').length - 1, 2, 'the tool in each'); + assert.equal(text.split('Play a point.').length - 1, 1, text); +}); + +const listOf = (tools: Json[]) => ({ kind: 'result', method: 'tools/list', params: {}, result: { tools }, awaited: true }); +const movedPeek = (presence: 'static' | 'dynamic') => TOOLS.map((x) => (x.name === 'peek' ? { ...x, _meta: { ...(x._meta as Json), 'mcpl/presence': presence } } : x)); + +test('a hint restating the presence a tools/list moved a tool to leaves the move to be told at the rebuild', () => { + const { c, told, read } = tappedController(); + read(listOf(TOOLS)); + read(listOf(movedPeek('static'))); + read({ kind: 'hints', params: { rev: 1, tools: [{ name: 'peek', presence: 'static' }] } }); + assert.doesNotMatch(told.join('\n'), /peek is in your tool list now/, 'not before the rebuild'); + c.applyMoves(); + assert.match(told.join('\n'), /peek is in your tool list now/); +}); + +test('a move a later tools/list reverses before the rebuild is never told', () => { + const { c, told, read } = tappedController(); + read(listOf(TOOLS)); + read(listOf(movedPeek('static'))); + read(listOf(movedPeek('dynamic'))); + c.applyMoves(); + assert.doesNotMatch(told.join('\n'), /peek (is in|left) your tool list/); +}); + +test('a push whose change no notice could carry keeps it for the push\'s own path to take', async () => { + let writes = false; + const { c, read } = tappedController(() => writes); + const params = { featureSet: 'arc.events', eventId: 'p1', payload: { content: [] }, hints: { rev: 1, tools: [{ name: 'peek', visible: true }] } }; + read({ kind: 'push', params }); + await nextMacrotask(); + writes = true; + assert.match(c.takeChangeText(params), /peek\(\)/); +}); + +test('a recovery whose own tools/list straddled a change starts no second recovery', async () => { + let paging = false; + let second: { msg: Json; peer: ScriptedPeer } | null = null; + const t = await setup((msg, peer, state) => { + if (msg.method !== 'tools/list' || !paging) return false; + if ((msg.params as Json | undefined)?.cursor === 'p2') second = { msg, peer }; + else peer.reply(msg.id, { tools: TOOLS.slice(0, 3), nextCursor: 'p2', _meta: { 'mcpl/rev': state.rev } }); + return true; + }); + try { + paging = true; + const from = t.server.received.length; + t.state.next(); + t.server.peer.notify('tools/hints', t.state.hint([])); + await waitFor(() => second !== null, 'the recovery\'s second page asked for'); + t.server.peer.notify('tools/hints', t.state.hint([{ name: 'peek', visible: true }])); + const held = second as unknown as { msg: Json; peer: ScriptedPeer }; + paging = false; + held.peer.reply(held.msg.id, { tools: TOOLS.slice(3), _meta: { 'mcpl/rev': t.state.rev } }); + await waitFor(() => t.server.received.slice(from).some((m) => m.method === 'toolsets/list'), 'the recovery read toolsets/list'); + await roundTrip(t); + await settle(400); + assert.equal(t.server.received.slice(from).filter((m) => m.method === 'toolsets/list').length, 1); + } finally { await t.done(); } +}); + +test('a definition a hint gives after a separate hinted move is kept for when the list makes the tool dynamic', () => { + const { told, read } = tappedController(); + read(listOf(TOOLS)); + read({ kind: 'hints', params: { rev: 1, tools: [{ name: 'status', presence: 'dynamic' }] } }); + read({ kind: 'hints', params: { rev: 2, tools: [{ name: 'status', definition: { description: 'Status, from a hint.', inputSchema: { type: 'object', properties: {} } } }] } }); + read(listOf(TOOLS.map((x) => (x.name === 'status' ? { ...x, _meta: { 'mcpl/presence': 'dynamic', 'mcpl/visible': true } } : x)))); + assert.match(told.join('\n'), /status\(\) — Status, from a hint\./); +}); + +test('a tool a server describes malformed (a description that isn\'t a string, a schema nested very deep) is still announced and looked at', async () => { + let deep: Json = { type: 'string' }; + for (let i = 0; i < 20000; i++) deep = { type: 'object', properties: { a: deep } }; + let rows: Json = { type: 'string' }; + for (let i = 0; i < 20000; i++) rows = { type: 'array', items: rows }; + const tools = [ + ...TOOLS.filter((x) => x.name !== 'peek'), + { name: 'peek', description: 42, inputSchema: { type: 'object', properties: {} }, _meta: { 'mcpl/presence': 'dynamic', 'mcpl/visible': false } }, + { name: 'delve', description: 'Delve.', inputSchema: deep, _meta: { 'mcpl/presence': 'dynamic', 'mcpl/visible': false } }, + { name: 'burrow', description: 'Burrow.', inputSchema: { type: 'object', properties: { rows } }, _meta: { 'mcpl/presence': 'dynamic', 'mcpl/visible': false } }, + ]; + const { hub, told, read } = tappedController(() => true, tools); + read({ kind: 'hints', params: { rev: 1, tools: [{ name: 'peek', visible: true }, { name: 'delve', visible: true }, { name: 'burrow', visible: true }] } }); + const text = told.join('\n'); + assert.match(text, /peek\(\)/); + assert.match(text, /delve\(a\?: .*\) — Delve\./); + assert.match(text, /burrow\(rows\?: .*\) — Burrow\./); + const looked = await new ToolsetsModule(hub).handleToolCall({ id: 'l1', name: 'look', input: { server: 'arc', tool: 'delve' } } as never); + assert.ok(!looked.isError, String(looked.error)); +}); + +test('look at a toolset whose options a server nests very deep still answers', async () => { + let deep: Json = { type: 'string' }; + for (let i = 0; i < 20000; i++) deep = { type: 'object', properties: { a: deep } }; + const { hub, c } = tappedController(); + c.manifestChanged({ toolHints: true, toolsets: { maze: { title: 'Maze', options: deep } } }); + const looked = await new ToolsetsModule(hub).handleToolCall({ id: 'l2', name: 'look', input: { server: 'arc', toolset: 'maze' } } as never); + assert.ok(!looked.isError, String(looked.error)); +}); diff --git a/test/mcpl-toolsets-e2e.test.ts b/test/mcpl-toolsets-e2e.test.ts index 10d1fc107..1da73f190 100644 --- a/test/mcpl-toolsets-e2e.test.ts +++ b/test/mcpl-toolsets-e2e.test.ts @@ -6,8 +6,7 @@ * - the four meta-tools (toolsets--open/act/look/close) are there, constant; * - an open announces the toolset's tools; act calls carry the instance and * report what the call changed; close reports what closing meant; - * - an instance the server kept is adopted after a reconnect (vector 9); - * - toolsets/list with nothing visible clears what the host believed (38). + * - an instance the server kept is adopted after a reconnect (vector 9). */ import { describe, it, before, after } from 'node:test'; import assert from 'node:assert/strict'; @@ -166,6 +165,7 @@ describe('RFC-014 host, end to end against the reference ToolsetServer', () => { await callTool(fw.framework, 'toolsets--open', { server: 'arcade', toolset: 'build' }); const closed = await callTool(fw.framework, 'toolsets--close', { server: 'arcade', toolset: 'build' }); assert.match(resultText(closed), /\[arcade\/build\] closed/); + await new Promise((r) => setImmediate(r)); // Nothing of it reached the window as a notice: the results carried it. assert.ok(!windowText(fw.framework).some((t) => t.includes('tool changes') && t.includes('[arcade/build]'))); }); From df8f8ea757b13571e418f657743ce14c102976b9 Mon Sep 17 00:00:00 2001 From: Octavia-2412 Date: Sun, 25 Oct 2026 18:48:03 -0400 Subject: [PATCH 02/46] fix(mcpl): a toolHints connect registers its abort before adding the toolsets module connectMcplServerInternal awaited ensureToolsetsModule() after its stopHasBegun check and before it registered the connect's abort, so neither stop() nor a disconnect of the id could end the connect while the meta-tools module was being added. - A disconnect then found no abort to fire: the connect went on to dial the server once the module was in, and only the post-handshake displaced() check removed it again. - A stop then made the connect fail with addModule()'s own refusal ("addModule() refused: the framework is stopping") instead of the connect's "was not connected: the framework stopped while it was connecting". The await now sits inside the try, after the abort is registered, so either ends the connect through the abort: the handshake sees the aborted signal before it opens a transport, and the catch words it as any other connect ended that way. A module-name collision still throws its own error. Tests: a disconnect and a stop while the module is being added (its addModule held open); both fail at 8aaee1ca, the first with one dial of the server, the second with addModule's refusal. Co-Authored-By: Claude Opus 5.5 --- src/framework.ts | 4 +-- test/mcpl-toolsets-wiring.test.ts | 60 +++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 test/mcpl-toolsets-wiring.test.ts diff --git a/src/framework.ts b/src/framework.ts index f97649828..00ebfbdc1 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -27810,11 +27810,11 @@ export class AgentFramework { let connection: McplServerConnection; // The transport the connect registered, which its own exchange is for. let registeredEpoch = 0; - // RFC-014: a toolHints server's meta-tools exist before it connects. - if (config.toolHints === true) await this.ensureToolsetsModule(); const abort = new AbortController(); this.legacyConnectAborts().set(config.id, abort); try { + // RFC-014: a toolHints server's meta-tools exist before it connects. + if (config.toolHints === true) await this.ensureToolsetsModule(); // Its policy exchange is in flight from its registration until the // exchange settles: an opener that runs meanwhile (a resume, startup's // staged open) passes the connection by, so nothing opens its planes diff --git a/test/mcpl-toolsets-wiring.test.ts b/test/mcpl-toolsets-wiring.test.ts new file mode 100644 index 000000000..7e45620f8 --- /dev/null +++ b/test/mcpl-toolsets-wiring.test.ts @@ -0,0 +1,60 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import type { Module } from '../src/types/index.js'; +import type { McplServerConfig } from '../src/mcpl/types.js'; +import { startScriptedServer, makeFramework, waitFor, type Json } from './helpers/toolset-servers.js'; + +const ADVERT: Json = { + toolHints: true, + featureSets: { 'arc.tools': { description: 'tools', uses: ['tools'] } }, +}; +const STATUS: Json = { name: 'status', description: 'Status of the arcade.', inputSchema: { type: 'object', properties: {} } }; + +async function heldAtModuleAdd() { + const fw = await makeFramework([]); + let release!: () => void; + const held = new Promise((resolve) => { release = resolve; }); + let adding = false; + const addModule = fw.framework.addModule.bind(fw.framework); + fw.framework.addModule = async (module: Module) => { adding = true; await held; return addModule(module); }; + return { fw, release, adding: () => adding }; +} + +test('a disconnect while a toolHints server is still connecting ends the connect before it dials the server', async () => { + const server = await startScriptedServer({ advert: ADVERT, tools: [STATUS] }); + const { fw, release, adding } = await heldAtModuleAdd(); + try { + const config: McplServerConfig = { id: 'arc', toolPrefix: 'arc', url: server.url, toolHints: true }; + const connect = fw.framework.connectMcplServer(config); + connect.catch(() => undefined); + await waitFor(adding, 'the toolsets module being added'); + const disconnect = fw.framework.disconnectMcplServer('arc'); + release(); + await assert.rejects(connect, /"arc" was not connected: it was disconnected while connecting/); + await disconnect; + assert.equal(server.peers.length, 0, 'nothing dialed the server'); + } finally { + await fw.stop(); + await server.close(); + } +}); + +test('a stop while a toolHints server is still connecting ends the connect, which says the framework stopped', async () => { + const server = await startScriptedServer({ advert: ADVERT, tools: [STATUS] }); + const { fw, release, adding } = await heldAtModuleAdd(); + try { + const config: McplServerConfig = { id: 'arc', toolPrefix: 'arc', url: server.url, toolHints: true }; + const connect = fw.framework.connectMcplServer(config); + connect.catch(() => undefined); + await waitFor(adding, 'the toolsets module being added'); + const stopped = fw.framework.stop(); + release(); + await assert.rejects(connect, /"arc" was not connected: the framework stopped while it was connecting/); + await stopped; + assert.equal(server.peers.length, 0, 'nothing dialed the server'); + } finally { + await fw.stop(); + await server.close(); + } +}); From 59e25fb269c9943ec83760349308369f1a8631a8 Mon Sep 17 00:00:00 2001 From: Octavia-2412 Date: Sun, 25 Oct 2026 18:50:10 -0400 Subject: [PATCH 03/46] fix(mcpl): refuse a toolsets selector that is there but isn't a name The meta-tools read server, toolset, instance and tool through str(), which turns anything that isn't a non-blank string into undefined, the same as leaving the argument out. So close {toolset: 5} or {instance: ""} closed the only open instance, and an act with a blank instance picked the only one open. The module's schema check covers required keys only. Each entry point (the module's handleToolCall and resolveAct, which the framework's act dispatch uses) now refuses a selector that is present but isn't a non-blank string, naming the argument and what it got, before anything is resolved or sent. Absent and null still read as left out. Tests: close with a blank instance, a numeric toolset and a list for a server, look with an object for a tool, and act with a blank instance are each refused, and nothing is closed or called; at 8aaee1ca the blank instance's close went to the server for g1. An act and a close whose selectors are null resolve as if they were left out; that passes at 8aaee1ca, and fails once null is refused. Co-Authored-By: Claude Opus 5.5 --- src/mcpl/toolsets-module.ts | 14 +++++++++ test/mcpl-tool-hints.test.ts | 55 ++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/src/mcpl/toolsets-module.ts b/src/mcpl/toolsets-module.ts index 43cd15c9e..92bc5564a 100644 --- a/src/mcpl/toolsets-module.ts +++ b/src/mcpl/toolsets-module.ts @@ -97,6 +97,18 @@ const META_TOOLS: ToolDefinition[] = [ ]; const str = (v: unknown): string | undefined => (typeof v === 'string' && v.trim() ? v.trim() : undefined); + +const SELECTORS = ['server', 'toolset', 'instance', 'tool'] as const; + +function checkSelectors(args: Record): void { + for (const key of SELECTORS) { + const v = args[key]; + if (v === undefined || v === null || str(v) !== undefined) continue; + const got = typeof v === 'string' ? 'a blank string' : Array.isArray(v) ? 'a list' : typeof v === 'object' ? 'an object' : `a ${typeof v}`; + fail(`${key} must be a name, or left out; got ${got}, so nothing was done`); + } +} + const isObj = (v: unknown): v is Record => typeof v === 'object' && v !== null && !Array.isArray(v); type Block = { type: string; text?: string; [k: string]: unknown }; @@ -178,6 +190,7 @@ export type ActTarget = { ok: true; serverId: string; tool: string; instance?: s export function resolveAct(hub: ToolsetHub, input: unknown): ActTarget { try { const args = isObj(input) ? input : {}; + checkSelectors(args); const tool = str(args.tool) ?? fail('name the tool'); const c = owner(hub, args.server, (x) => !!x.presence.tool(tool), `a tool "${tool}"`); const p = c.presence; @@ -241,6 +254,7 @@ export class ToolsetsModule implements Module { async handleToolCall(call: ToolCall): Promise { const args = isObj(call.input) ? call.input : {}; try { + checkSelectors(args); switch (call.name) { case 'open': return await this.open(args); case 'act': return this.act(args); diff --git a/test/mcpl-tool-hints.test.ts b/test/mcpl-tool-hints.test.ts index 4494062fa..dcb967871 100644 --- a/test/mcpl-tool-hints.test.ts +++ b/test/mcpl-tool-hints.test.ts @@ -535,6 +535,61 @@ test('close refuses an instance for a toolset without instances, and sends nothi } finally { await t.done(); } }); +test('a selector that is there but isn\'t a name is refused, not read as left out: nothing is closed or called', async () => { + const t = await setup((msg, peer, state) => { + if (msg.method === 'toolsets/open') { + state.open = [{ toolset: 'game', instance: 'g1', title: 'G1' }]; + peer.reply(msg.id, { toolset: 'game', instance: 'g1', title: 'G1', _meta: { 'mcpl/hints': state.hint([{ name: 'play', instance: 'g1', visible: true }]) } }); + return true; + } + return false; + }); + try { + await callTool(t.fw.framework, 'toolsets--open', { server: 'arc', toolset: 'game' }); + const refusals: Array<[string, Json, RegExp]> = [ + ['toolsets--close', { server: 'arc', toolset: 'game', instance: '' }, /instance must be a name, or left out; got a blank string/], + ['toolsets--close', { server: 'arc', toolset: 5 }, /toolset must be a name, or left out; got a number/], + ['toolsets--close', { server: ['arc'] }, /server must be a name, or left out; got a list/], + ['toolsets--look', { server: 'arc', tool: { name: 'play' } }, /tool must be a name, or left out; got an object/], + ['toolsets--act', { server: 'arc', tool: 'play', instance: ' ', args: { point: 'D4' } }, /instance must be a name, or left out; got a blank string/], + ]; + for (const [name, input, refusal] of refusals) { + const r = await callTool(t.fw.framework, name, input); + assert.ok(r.isError, `${name} ${JSON.stringify(input)} is refused`); + assert.match(resultText(r), refusal); + } + assert.ok(!t.methods().includes('toolsets/close'), 'nothing closed'); + assert.ok(!t.server.received.some((m) => m.method === 'tools/call' && (m.params as Json).name === 'play'), 'nothing called'); + assert.match(await look(t.fw), /G1/, 'g1 is still open'); + } finally { await t.done(); } +}); + +test('a selector given as null reads as left out', async () => { + const t = await setup((msg, peer, state) => { + if (msg.method === 'toolsets/open') { + state.open = [{ toolset: 'game', instance: 'g1', title: 'G1' }]; + peer.reply(msg.id, { toolset: 'game', instance: 'g1', title: 'G1', _meta: { 'mcpl/hints': state.hint([{ name: 'play', instance: 'g1', visible: true }]) } }); + return true; + } + if (msg.method === 'toolsets/close') { + state.open = []; + peer.reply(msg.id, { _meta: { 'mcpl/hints': state.hint([{ name: 'play', instance: 'g1', visible: false }]) } }); + return true; + } + return false; + }); + try { + await callTool(t.fw.framework, 'toolsets--open', { server: 'arc', toolset: 'game', instance: 'g1' }); + const played = await callTool(t.fw.framework, 'toolsets--act', { server: null, tool: 'play', instance: null, args: { point: 'D4' } }); + assert.ok(!played.isError, resultText(played)); + const call = t.server.received.find((m) => m.method === 'tools/call' && (m.params as Json).name === 'play'); + assert.deepEqual((call?.params as Json | undefined)?._meta, { 'mcpl/instance': 'g1' }, 'the one open instance, as with instance left out'); + const closed = await callTool(t.fw.framework, 'toolsets--close', { server: 'arc', toolset: null, instance: null }); + assert.ok(!closed.isError, resultText(closed)); + assert.ok(t.methods().includes('toolsets/close'), 'the one open thing closed'); + } finally { await t.done(); } +}); + test('the host\'s argument check is bounded: no server pattern runs, and deep or wide schemas are left to the server', async () => { const { checkArgs } = await import('../src/mcpl/tool-signature.js'); // A pattern that backtracks catastrophically is never evaluated. From bd3eddf9e9d49480d277d2a27eb52f805f08deaf Mon Sep 17 00:00:00 2001 From: Octavia-2412 Date: Sun, 25 Oct 2026 18:51:52 -0400 Subject: [PATCH 04/46] fix(mcpl): an open or close whose outcome is unknown is reported as unconfirmed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit toolsets--open and toolsets--close wrapped every failure of their request as " did not open : ", while the connection's own error says whether the request took effect is unknown: a timeout, a connection lost before the answer, or a JSON-RPC error (which the connection classes as possibly having acted). A model told "did not open" may open again, and an open without a name makes a second instance (RFC-014 §5.2). Now only a request known not to have been sent (McplRequestError's "not-sent") says "did not". The controller's refusal before sending, when the server isn't connected, is such an error now, so it still says "did not". Any other failure says " didn't confirm opening ", keeps the connection's text as a sentence, and adds that toolsets--look {server} asks it what's open (withLookHint). Tests: an open whose connection drops before the answer, and a close the server answers with an error, are each reported as unconfirmed with look named; at 8aaee1ca the first read "arc did not open build: ... whether the request took effect is unknown". With the connection gone, a second open says "arc did not open build: arc is not connected right now; try again later"; that fails if the controller's refusal is a plain Error. Co-Authored-By: Claude Opus 5.5 --- src/mcpl/toolset-host.ts | 4 ++-- src/mcpl/toolsets-module.ts | 18 ++++++++++++++++-- test/mcpl-tool-hints.test.ts | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 4 deletions(-) diff --git a/src/mcpl/toolset-host.ts b/src/mcpl/toolset-host.ts index f202f7635..0f69b11b7 100644 --- a/src/mcpl/toolset-host.ts +++ b/src/mcpl/toolset-host.ts @@ -23,7 +23,7 @@ * which the toolsets module gathers at its next start as a stored * `afterUser` injection. */ -import type { McplServerConnection, HintRead } from './server-connection.js'; +import { McplRequestError, type McplServerConnection, type HintRead } from './server-connection.js'; import type { McplServerConfig } from './types.js'; import { isToolAllowed } from './tool-policy.js'; import { @@ -141,7 +141,7 @@ export class ToolsetController { /** The live connection, for a request. */ private live(): McplServerConnection { const c = this.connection; - if (!c || !c.isConnected) throw new Error(`${this.serverId} is not connected right now; try again later`); + if (!c || !c.isConnected) throw new McplRequestError(`${this.serverId} is not connected right now; try again later`, 'not-sent'); this.sync(c.transportEpoch, c); return c; } diff --git a/src/mcpl/toolsets-module.ts b/src/mcpl/toolsets-module.ts index 92bc5564a..891daf799 100644 --- a/src/mcpl/toolsets-module.ts +++ b/src/mcpl/toolsets-module.ts @@ -28,6 +28,7 @@ import { unknownToolMessage } from '../modules/unknown-tool.js'; import { META_TOOL_NAMES, TOOLSETS_MODULE, type ToolsetController, type ToolsetHub } from './toolset-host.js'; import { keyOf, type ChangeSet } from './tool-presence.js'; import { checkArgs, jsonText, renderToolDefinition, type HintedTool } from './tool-signature.js'; +import { McplRequestError } from './server-connection.js'; /** The namespace a reminder's stored injection carries. */ export const TOOLSETS_REMINDER_NAMESPACE = 'mcpl-toolsets'; @@ -109,6 +110,18 @@ function checkSelectors(args: Record): void { } } +/** A failure whose outcome is unknown, with how to find it out. */ +export function withLookHint(serverId: string, text: string): string { + const said = text.trimEnd(); + return `${said}${/[.!?]$/.test(said) ? '' : '.'} ${META_TOOL_NAMES.look} {server:"${serverId}"} asks it what's open.`; +} + +function requestFailure(serverId: string, did: string, unconfirmed: string, error: unknown): never { + const message = error instanceof Error ? error.message : String(error); + if (error instanceof McplRequestError && error.outcome === 'not-sent') return fail(`${serverId} ${did}: ${message}`); + return fail(withLookHint(serverId, `${serverId} ${unconfirmed}: ${message}`)); +} + const isObj = (v: unknown): v is Record => typeof v === 'object' && v !== null && !Array.isArray(v); type Block = { type: string; text?: string; [k: string]: unknown }; @@ -302,7 +315,7 @@ export class ToolsetsModule implements Module { try { opened = await c.open(toolset, instance, options); } catch (error) { - return fail(`${c.serverId} did not open ${toolset}: ${error instanceof Error ? error.message : String(error)}`); + return requestFailure(c.serverId, `did not open ${toolset}`, `didn't confirm opening ${toolset}`, error); } // RFC §5.2: opening what is open adopts it; its content says which options weren't applied. const blocks: Block[] = [ @@ -466,7 +479,8 @@ export class ToolsetsModule implements Module { try { closed = await c.close(toolset!, instance); } catch (error) { - return fail(`${c.serverId} did not close ${toolset}${instance ? `:${instance}` : ''}: ${error instanceof Error ? error.message : String(error)}`); + const what = `${toolset}${instance ? `:${instance}` : ''}`; + return requestFailure(c.serverId, `did not close ${what}`, `didn't confirm closing ${what}`, error); } const key = keyOf(toolset!, instance); const change = p.render(closed.change) || `${p.label(key)} closed.`; diff --git a/test/mcpl-tool-hints.test.ts b/test/mcpl-tool-hints.test.ts index dcb967871..d22b65a41 100644 --- a/test/mcpl-tool-hints.test.ts +++ b/test/mcpl-tool-hints.test.ts @@ -590,6 +590,39 @@ test('a selector given as null reads as left out', async () => { } finally { await t.done(); } }); +test('an open or close whose outcome the host can\'t know is reported as unconfirmed, with look named; one known not sent, as not done', async () => { + const dropped = await setup((msg, peer) => { + if (msg.method !== 'toolsets/open') return false; + peer.ws.terminate(); + return true; + }, { reconnect: true, reconnectIntervalMs: 60_000 }); + try { + const r = await callTool(dropped.fw.framework, 'toolsets--open', { server: 'arc', toolset: 'build' }); + assert.ok(r.isError); + assert.match(resultText(r), /^arc didn't confirm opening build: The connection to the "arc" server closed before it answered/); + assert.match(resultText(r), /toolsets--look \{server:"arc"\} asks it what's open\.$/); + const again = await callTool(dropped.fw.framework, 'toolsets--open', { server: 'arc', toolset: 'build' }); + assert.ok(again.isError); + assert.equal(resultText(again), 'arc did not open build: arc is not connected right now; try again later', 'with the connection gone, an open is known not sent'); + } finally { await dropped.done(); } + + const refused = await setup((msg, peer, state) => { + if (msg.method === 'toolsets/open') { + state.open = [{ toolset: 'build', title: 'B' }]; + peer.reply(msg.id, { toolset: 'build', title: 'B', _meta: { 'mcpl/hints': state.hint([{ name: 'place', visible: true }]) } }); + return true; + } + if (msg.method === 'toolsets/close') { peer.error(msg.id, -32000, 'the board is locked'); return true; } + return false; + }); + try { + await callTool(refused.fw.framework, 'toolsets--open', { server: 'arc', toolset: 'build' }); + const r = await callTool(refused.fw.framework, 'toolsets--close', { server: 'arc', toolset: 'build' }); + assert.ok(r.isError); + assert.match(resultText(r), /^arc didn't confirm closing build: .*the board is locked\. toolsets--look \{server:"arc"\} asks it what's open\.$/); + } finally { await refused.done(); } +}); + test('the host\'s argument check is bounded: no server pattern runs, and deep or wide schemas are left to the server', async () => { const { checkArgs } = await import('../src/mcpl/tool-signature.js'); // A pattern that backtracks catastrophically is never evaluated. From 75d5527f7406ab5dcfadd75bbe1d0cf8099dc536 Mon Sep 17 00:00:00 2001 From: Octavia-2412 Date: Sun, 25 Oct 2026 19:01:11 -0400 Subject: [PATCH 05/46] fix(mcpl): a result's tool change goes with it only where it reaches an agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seam that "tool hints follow the server's statements, and a change counts as told only when an agent reads it" put in place: takeChange(carrier) takes a carrier's change without marking it, and must be called as the carrier is read, before the next macrotask; told(cs) counts a change as shown; tell(cs) writes it as a notice, or drops it when none can be written. RFC-014 §6.1 and §6.2: a call reports its own consequences, and every change reaches the agent once. Who takes a change (changeTaker): - The model's own call of a dynamic tool takes it as the server's result is read, and adds it to the tool result after the result has been read for the model. withToolsetChange had appended the change to the server's content first, and the model's reading shows structuredContent only when content is empty, so a structured-only answer that carried hints reached the model as the change text alone. The change is counted told once the result is handed back. An open, close or look the model calls counts its change told as the module takes it. - The operator's puppet stores its call and result in the agent's window as a model's pair, so it takes the change too, keyed by call id (which the call toolsets--act resolves to keeps), and counts it told once the pair is stored. It stores a structured-only answer that isn't an error as that content's JSON, as the model's reading shows it. An open's or look's rendering counts the definitions it describes as shown as it renders them, so a notice after a failed store names them without describing them. - Anything else (a script's call, a module's or a host's call in process) may never reach an agent, so its change is told as a notice. This holds for open, close and look as well as a dynamic tool's result: the controller's open, close and look re-read take a shown flag, and an unshown call leaves the change for its notice. Its result also shows the change to its caller, every tool described and nothing counted shown (render's describeAll, catalog's mark), unless a message read after it in the same chunk had the change told as a notice first. Ways out, each telling the change as a notice instead: - a dynamic tool's change that wouldn't fit whole within its result's inline cap, as the store renders the result when the call returns (the store's own storedToolResultText, at the call's cap, with the change compared as the store makes it well-formed), so the result is left whole rather than cut with the change in its tail; - a model's call that fails after its result was read; - a puppet whose pair can't be stored, or whose result is replaced by the stored error. Also: - A notice dropped because no agent is registered is logged, as the drop for want of pushEvents already was; both lines say look shows the state the change left. - look says when it couldn't re-read a server ("X couldn't be asked just now: this is what the host last knew of it."), in its answer and in a refusal. The look hint an unconfirmed open, close or act gives says look asks the server what's open, or says it couldn't. - ToolsetsModule's constructor takes the taker; left out, every call's result is taken to reach the agent, as 0.27.0's constructor did. - withToolsetChange, which nothing calls now, is gone; withChangeText adds a change to a ToolResult in the same places the reading would have put a text block. - The toolset test helper's makeFramework takes config options. - The README says which calls' changes go as notices, and that look says when it couldn't ask. Tests, each from a requirement. All fail at 8aaee1ca except two: the puppet's unstored pair, since #799's puppet took no change, and the lone surrogate, since #799 appended every change: - a model's call answered with structured content alone shows it, then the change, and nothing is told again (8aaee1ca: change text only); - a script's call leaves the change to a notice and its output doesn't hold it (8aaee1ca: the change in the script's output, no notice); - the puppet's stored result holds the change, and nothing is told again; it stores a structured-only answer as its JSON before the change; its toolsets--act stores the dynamic tool's result with the change; - a puppet whose pair can't be stored tells the change as a notice (fails with the notice dropped, or with the change told at the take); - a model's call whose result plus change would pass the inline cap keeps its result whole and tells the change (fails with no fit check); - a model's call whose change fits goes with its result where a definition holds a lone surrogate (fails when the change is compared as it came); - a model's call whose result the host can't read (structured content nested past what JSON.stringify can take) tells the change (fails with the failed dispatch's tell removed); - an open's change goes with the puppet's stored result, read from the stored pair, and as a notice for a host's own open (fails if a private call counts as shown, or if the puppet stores a result without the change); a host's own close is told as a notice too; - look says when it couldn't ask a server, in its answer and in a filtered look's refusal (fails with the refusal's line removed); - a change with no agent to tell is logged; - a standalone change is a notice only where pushEvents is granted, a notice starts no turn, and without pushEvents look shows the change. The existing tests that read the change in a host's own look or act's result now read it through the puppet, or check only the change they name. The tests that build a ToolsetsModule themselves use 0.27.0's form, ToolsetsModule(hub), which doesn't compile without the taker's default (TS2554). A look through ToolsetsModule(hub) takes its change as told, so no notice follows, and with a null taker it's left to a notice; that fails when the default leaves the change to a notice. Co-Authored-By: Claude Opus 5.5 --- README.md | 13 +- src/framework.ts | 119 ++++++++++--- src/mcpl/tool-presence.ts | 15 +- src/mcpl/toolset-host.ts | 27 ++- src/mcpl/toolsets-module.ts | 100 +++++++---- test/helpers/toolset-servers.ts | 6 +- test/mcpl-tool-hints.test.ts | 78 ++++++++- test/mcpl-toolsets-e2e.test.ts | 6 +- test/mcpl-toolsets-wiring.test.ts | 268 +++++++++++++++++++++++++++++- 9 files changed, 538 insertions(+), 94 deletions(-) diff --git a/README.md b/README.md index a918597ce..5d1e0f198 100644 --- a/README.md +++ b/README.md @@ -456,8 +456,9 @@ framework registers a `toolsets` module with four constant tools: call itself; - `toolsets--look {server?, toolset?, instance?, tool?}` restates what is open and what can be opened, or one tool's description and input schema. It - re-reads the server first; what that changed is in the answer when look - restates everything, and otherwise told as a notice; + re-reads the server first, and says so when it couldn't; what that changed + is in the answer when look restates everything, and otherwise told as a + notice; - `toolsets--close {server?, toolset?, instance?}` closes it. `server` can be left out when only one server has the name. Hint changes apply @@ -472,8 +473,12 @@ never re-opening anything. What changed is told where it happened: after a call's own result, after the push that explains it, or as a notice (`kind: 'mcpl-toolsets'`, which wakes nothing) for a standalone `tools/hints`, a late result, an adoption, a -recovery, or a call whose result doesn't go to the agent (one the framework or -a module makes itself). A call's change also goes as a notice, ahead of its +recovery, or a call whose result may not reach the agent (a script's, a +module's or a host's, or one the framework makes itself); an open, close or +look made that way also shows its caller the change, unless the change has +already gone as a notice (below). A dynamic tool's change also goes as a notice +when it wouldn't fit whole within its result's inline cap, or when its call +fails after the result was read. A call's change also goes as a notice, ahead of its result, when a later change goes to the agent before the call's caller has taken it, so changes reach the agent in the order they were read. Notices and reminders need the server's `pushEvents` grant, like any delivery; without it, a change diff --git a/src/framework.ts b/src/framework.ts index 00ebfbdc1..b26d2776d 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -594,6 +594,14 @@ function serverAddressOf(cfg: McplServerConfig): ServerAddress { return { url: cfg.url }; } +function withChangeText(result: ToolResult, change: string): ToolResult { + if (!change) return result; + if (result.isError) return { ...result, error: result.error ? `${result.error}\n${change}` : change }; + if (Array.isArray(result.data)) return { ...result, data: [...result.data, { type: 'text', text: change }] }; + if (typeof result.data === 'string' && result.data) return { ...result, data: `${result.data}\n${change}` }; + return { ...result, data: change }; +} + /** * The text of an MCPL tools/call result: its text blocks' text, joined by * line breaks, with empty ones left out. A text block whose text isn't a @@ -755,7 +763,8 @@ import { EPHEMERAL_USAGE_NAME, UsageTracker, persistedUsageProblem, type Persist import type { SessionUsageSnapshot, UsageUpdatedEvent } from './usage/types.js'; import { McplProtocolVersionError, McplRequestError, McplUnreapedLaunchError, stderrTailOf, toolsetDeclarationsOf, withStderr, type McplConnectionCloseInfo, type McplServerConnection } from './mcpl/server-connection.js'; import { ToolsetHub, TOOLSETS_MODULE, META_TOOL_NAMES } from './mcpl/toolset-host.js'; -import { ToolsetsModule, resolveAct } from './mcpl/toolsets-module.js'; +import { isEmptyChange } from './mcpl/tool-presence.js'; +import { ToolsetsModule, resolveAct, type TakenChange } from './mcpl/toolsets-module.js'; import { ModernMcpConnection } from './mcpl/modern-connection.js'; import { WITHHELD_TEXT, asText, credentialRedactor, redactUrl, redactServerText, resolveServerBinding, serverConfigProblems, serverCredentialPatterns, serverTextRedactor, type CredentialPattern } from './mcpl/protocol-family.js'; import { MALFORMED_CONTENT_NOTICE, normalizeStandardToolResult } from './mcpl/tool-result-normalize.js'; @@ -22133,6 +22142,9 @@ export class AgentFramework { const turnToken = this.nextTurnToken++; this.activeTurnTokens.set(agentName, turnToken); let ownedToEnd = false; + const changes: TakenChange[] = []; + (this.puppetChanges ??= new Map()).set(toolUseId, changes); + let stored = false; try { const started = Date.now(); // The pair is stored under the cap its call carried, the agent's inline cap at the call: a call @@ -22188,6 +22200,7 @@ export class AgentFramework { // must not lose the pair from a persisted queue. if (this.quiesced || this.deferredWritesPersisted) this.persistDeferredWrites(); } + stored = checked.usable; this.emitTrace({ type: 'puppet:tool-call', @@ -22204,6 +22217,11 @@ export class AgentFramework { ); return { toolUseId, result: checked.usable ? returned : result }; } finally { + this.puppetChanges?.delete(toolUseId); + for (const { toolsets, cs } of changes) { + if (stored) toolsets.told(cs); + else toolsets.tell(cs); + } if (this.activeTurnTokens.get(agentName) === turnToken) this.activeTurnTokens.delete(agentName); // Messages deferred while we held the token land now, after the pair // — the same end-of-turn flush driveStream performs, under the same @@ -22309,6 +22327,20 @@ export class AgentFramework { */ private toolsetActCalls?: WeakMap; + private puppetChanges?: Map; + + /** RFC-014 §6.1: a change reaches the agent once, so it goes with a result + * only where the result reaches an agent; null where it may not. */ + private changeTaker(call: ToolCall): ((taken: TakenChange) => void) | null { + if (call.callerAgentName === undefined) return null; + const puppet = this.puppetChanges?.get(call.id); + if (puppet) return (taken) => puppet.push(taken); + if (this.scriptToolWaiters.has(call.id)) return null; + const agent = this.agents.get(call.callerAgentName); + if (agent?.state.status !== 'waiting_for_tools' || !agent.state.pending.has(call.id)) return null; + return ({ toolsets, cs }) => toolsets.told(cs); + } + /** A toolsets--act call resolved (resolveAct) to the call of the server's * tool it makes, under that tool's model-facing name and with its * arguments, its id kept; or the reason it can't be sent. */ @@ -22350,23 +22382,12 @@ export class AgentFramework { this.dispatchMcplToolCall(agentName, target.call, target.serverId, target.prefix); } - /** - * RFC-014 §6.2: a tool result that goes to the agent, with the tool change - * its hints made (applied as the result was read) told after the server's - * content; taking it means it isn't told again as a notice. The result - * itself when there's none, as for every server without toolHints. - */ - private withToolsetChange(serverId: string, result: R): R { - const change = this.toolsetHub?.get(serverId)?.takeChangeText(result) ?? ''; - if (!change) return result; - return { ...result, content: [...(Array.isArray(result.content) ? result.content : []), { type: 'text', text: change }] }; - } - /** A programmatic call of an MCPL tool: `serverId` and `prefix` as * resolveMcplTool matched them, or as toolsets--act resolved them - * (toolsetActCalls). Its RFC-014 tool change isn't shown with its result, - * which goes to its caller, not necessarily to the agent: it's told as a - * notice (ToolsetController.tellUnlessClaimed). */ + * (toolsetActCalls). Its RFC-014 tool change goes with its result only + * where the result reaches an agent (changeTaker); any other caller's + * result may never reach one, so its change is told as a notice + * (ToolsetController.tellUnlessClaimed). */ private async executeMcplToolCall(call: ToolCall, serverId: string, prefix: string): Promise { if (!this.mcplServerRegistry) { return { success: false, error: 'MCPL not initialized, so the call didn\'t run', isError: true }; @@ -22387,10 +22408,25 @@ export class AgentFramework { const dynamicRefusal = modern || act ? null : this.toolsetHub?.get(serverId)?.dynamicCallRefusal(toolName, call.name) ?? null; if (dynamicRefusal) return { success: false, error: dynamicRefusal, isError: true }; const args = (call.input && typeof call.input === 'object') ? call.input as Record : {}; + let taken: TakenChange | undefined; try { const result = modern ? await modern.callTool(toolName, args) : await server!.sendToolsCall(toolName, args, undefined, act?.instance !== undefined ? { 'mcpl/instance': act.instance } : undefined); + const agentName = call.callerAgentName; + const taker = modern || agentName === undefined || !(result.isError || result.content === undefined || Array.isArray(result.content)) + ? null : this.changeTaker(call); + const toolsets = taker ? this.toolsetHub?.get(serverId) : undefined; + const cs = toolsets?.takeChange(result); + if (toolsets && cs && !isEmptyChange(cs)) taken = { toolsets, cs }; + const withChange = (answer: ToolResult): ToolResult => { + if (!taken) return answer; + const joined = this.withWholeChange(agentName!, call, answer, taken.toolsets.presence.render(taken.cs, { mark: false }), call.resultMaxChars); + if (joined) taker!(taken); + else taken.toolsets.tell(taken.cs); + taken = undefined; + return joined ?? answer; + }; // A server's error is a failure here too, as the model's dispatch // reads it: its text (mcplToolFailure), without its other blocks, and // its structured content, kept by presence. A success is the server's @@ -22398,7 +22434,7 @@ export class AgentFramework { const direct = AgentFramework.directToolResult(result, serverId, call.name); if (direct.isError) { const { data: _blocks, ...failure } = direct; - return failure; + return withChange(failure); } // RFC-005 §6.2: register an MCPL peer's references with this server, so // a stub later made of them is bound and fetch_reference can fetch @@ -22413,10 +22449,15 @@ export class AgentFramework { if (server && server.capabilities !== null) { await this.autoFetchReferences(result.content, serverId, 'register') .catch((e) => console.error('[rfc005] reference registration error:', (e as Error).message)); - return Array.isArray(direct.data) ? { ...direct, dataIsContent: true } : direct; + const blocks = Array.isArray(direct.data) ? direct.data : []; + const read: ToolResult = taker && blocks.length === 0 && Object.prototype.hasOwnProperty.call(direct, 'structured') + ? { ...direct, data: JSON.stringify(direct.structured) ?? 'null' } + : Array.isArray(direct.data) ? { ...direct, dataIsContent: true } : direct; + return withChange(read); } - return direct; + return withChange(direct); } catch (err) { + if (taken) taken.toolsets.tell(taken.cs); return { success: false, error: redactServerText(err instanceof Error ? err.message : err, config ?? {}), @@ -22425,6 +22466,12 @@ export class AgentFramework { } } + private withWholeChange(agentName: string, call: ToolCall, result: ToolResult, change: string, maxChars: number | undefined): ToolResult | null { + const joined = withChangeText(result, change); + const { text } = this.storedToolResultText(agentName, { id: call.id, name: call.name, input: call.input, result: joined, durationMs: 0 }, maxChars); + return text.endsWith(change.toWellFormed()) && (maxChars === undefined || text.length <= maxChars) ? joined : null; + } + // --------------------------------------------------------------------------- // Client-side programmatic tool calling (code_execution) // --------------------------------------------------------------------------- @@ -27611,7 +27658,7 @@ export class AgentFramework { if (existing) { throw new Error(`A module named "${TOOLSETS_MODULE}" is registered, and a server configured with toolHints needs that name for its meta-tools`); } - await this.addModule(new ToolsetsModule(hub)); + await this.addModule(new ToolsetsModule(hub, (call) => this.changeTaker(call))); } /** RFC-014 §5.3: after a (re)connect whose policy has settled, read what @@ -27637,10 +27684,13 @@ export class AgentFramework { private recordToolsetNotice(serverId: string, text: string, opts: { invite: boolean }): boolean { const connection = this.mcplServerRegistry?.getServer(serverId); if (!CapabilityGrant.of(connection).has('pushEvents')) { - console.error(`[mcpl] ${serverId}: a tool change was not told (pushEvents is not granted); ${META_TOOL_NAMES.look} shows it`); + console.error(`[mcpl] ${serverId}: a tool change was not told (pushEvents is not granted); ${META_TOOL_NAMES.look} shows the state it left`); + return false; + } + if (this.agents.size === 0) { + console.error(`[mcpl] ${serverId}: a tool change was not told (no agent is registered to tell); ${META_TOOL_NAMES.look} shows the state it left`); return false; } - if (this.agents.size === 0) return false; try { this.addMessage( 'user', @@ -30108,7 +30158,12 @@ export class AgentFramework { const readThrough = sender ? this.publishReadThrough(sender, call.id) : null; const dispatchRedactor = serverTextRedactor(config ?? {}); + let taken: TakenChange | undefined; const failDispatch = (error: unknown) => { + if (taken) { + taken.toolsets.tell(taken.cs); + taken = undefined; + } const raw = error instanceof Error ? error : new Error(String(error)); // Server and transport text, as every record of it is, with the // configuration the call was dispatched under. @@ -30285,10 +30340,13 @@ export class AgentFramework { } legacy.sendToolsCall(toolName, args, stateParams, act?.instance !== undefined ? { 'mcpl/instance': act.instance } : undefined) - // RFC-014 §6.2: a call reports its own consequences; the tool change - // its result carried is told after the server's content. - .then((result) => this.withToolsetChange(serverId, result)) .then(async (result) => { + // RFC-014 §6.2: a call reports its own consequences, so the tool + // change its result carried is told after the server's content, to + // the agent that reads it; a script's result leaves it to a notice. + const toolsets = forScript || !mcplPeer ? undefined : this.toolsetHub?.get(serverId); + const cs = toolsets?.takeChange(result); + if (toolsets && cs && !isEmptyChange(cs)) taken = { toolsets, cs }; // RFC-005: register references and fetch the small ones, and every // one a script will read, before serialization, so their stubs carry // a saved path. Only an MCPL peer's blocks are references; an @@ -30320,7 +30378,14 @@ export class AgentFramework { await this.autoFetchReferences(result.content, serverId, mode, claims) .catch((e) => console.error('[rfc005] autofetch error:', (e as Error).message)); } - return { result, toolResult: await reading(result, mcplPeer) }; + const read = await reading(result, mcplPeer); + if (!taken) return { result, toolResult: read }; + const joined = this.withWholeChange(agentName, call, read, taken.toolsets.presence.render(taken.cs, { mark: false }), this.resultMaxCharsFor(agentName, call.id)); + if (!joined) { + taken.toolsets.tell(taken.cs); + taken = undefined; + } + return { result, toolResult: joined ?? read }; }) .then(({ result, toolResult: answer }) => { let toolResult = answer; @@ -30426,6 +30491,8 @@ export class AgentFramework { moduleName: `mcpl:${serverId}`, result: toolResult, }); + if (taken) taken.toolsets.told(taken.cs); + taken = undefined; }) .catch(failDispatch); } diff --git a/src/mcpl/tool-presence.ts b/src/mcpl/tool-presence.ts index ad4219c57..880b6cc48 100644 --- a/src/mcpl/tool-presence.ts +++ b/src/mcpl/tool-presence.ts @@ -605,9 +605,10 @@ export class ToolPresence { * What the model reads for a change set. `bare`: a key whose header the * caller already printed. `mark: false` renders without counting the * definitions it describes as shown, for text that may not reach the model - * (markShown counts them once it has). + * (markShown counts them once it has). `describeAll`: every tool with its + * first sentence, as for a result that may reach no agent. */ - render(cs: ChangeSet, opts: { bare?: string; mark?: boolean } = {}): string { + render(cs: ChangeSet, opts: { bare?: string; mark?: boolean; describeAll?: boolean } = {}): string { const local = new Set(); const seen: Shown = opts.mark === false ? { has: (d) => this.shown.has(d) || local.has(d), add: (d) => { local.add(d); } } : this.shown; const lines: string[] = []; @@ -619,7 +620,7 @@ export class ToolPresence { } const title = (c.opened || c.retitled) && !bare ? c.title : undefined; const setChanged = c.opened || c.appeared.length > 0 || c.redefined.length > 0 || c.gone.length > 0; - if (setChanged) lines.push(...this.toolBlock(c.key, c.now, { headed: !bare, title, seen })); + if (setChanged) lines.push(...this.toolBlock(c.key, c.now, { headed: !bare, title, seen, describeAll: opts.describeAll })); else if (title) lines.push(`${this.label(c.key)} ${title}`); if (c.gone.length) lines.push(`now unavailable: ${c.gone.join(', ')}`); if (c.opened && !bare) { @@ -657,14 +658,16 @@ export class ToolPresence { return lines.join('\n'); } - /** look naming this server: what is open (with tools) and what can be opened. */ - catalog(): string { + /** look naming this server: what is open (with tools) and what can be + * opened. `mark: false`, as for render, for a look whose result may + * reach no agent. */ + catalog(opts: { mark?: boolean } = {}): string { const lines: string[] = []; lines.push(this.surface ? `${this.server} — ${[this.surface.title, this.surface.summary].filter(Boolean).join(': ')}` : this.server); const v = this.view(); if (v.size) { for (const [key, e] of v) { - lines.push(...this.toolBlock(key, [...e.tools.values()], { describeAll: true, title: e.title }).map((l) => ` ${l}`)); + lines.push(...this.toolBlock(key, [...e.tools.values()], { describeAll: true, title: e.title, seen: opts.mark === false ? new Set() : undefined }).map((l) => ` ${l}`)); const close = this.closeHint(key); if (close) lines.push(` ${close} closes it.`); } diff --git a/src/mcpl/toolset-host.ts b/src/mcpl/toolset-host.ts index 0f69b11b7..167af18a8 100644 --- a/src/mcpl/toolset-host.ts +++ b/src/mcpl/toolset-host.ts @@ -416,6 +416,10 @@ export class ToolsetController { this.notice(cs); } + private changeOf(carrier: object): ChangeSet { + return this.readChanges.get(carrier) ?? emptyChange(); + } + /** A carrier's change, taken to be told some other way (a notice this controller sends). */ private claim(carrier: unknown): ChangeSet { return this.take(carrier); @@ -525,16 +529,19 @@ export class ToolsetController { /** * Re-read what the server holds open (titles, visibility) without telling * it, for look. Returns what the re-read changed, taken from the notice it - * would otherwise get: look then either shows it (told) or hands it back - * (tell). Null when nothing was read. + * would otherwise get when `shown` (its result reaches an agent): look then + * either shows it (told) or hands it back (tell). Otherwise the change is + * left to its notice, and returned to be shown to the caller alone. Null + * when nothing was read. */ - async refreshOpen(): Promise { + async refreshOpen(opts: { shown: boolean }): Promise { if (!this.presence.active) return null; const connection = this.connection; if (!connection || !connection.isConnected) return null; try { const r = await connection.sendToolsetsList(REFRESH_TIMEOUT_MS); - return isObj(r) ? this.claim(r) : null; + if (!isObj(r)) return null; + return opts.shown ? this.claim(r) : this.changeOf(r); } catch (error) { this.log(`toolsets/list (look) failed: ${error instanceof Error ? error.message : String(error)}`); return null; @@ -543,8 +550,10 @@ export class ToolsetController { // ── the host's acts ── - /** toolsets/open (RFC §5.2). The server's refusal rejects. */ - async open(toolset: string, instance: string | undefined, options: Record | undefined): Promise<{ + /** toolsets/open (RFC §5.2). The server's refusal rejects. Its change is + * taken when `shown` (its result reaches an agent), else left to its + * notice and returned for the caller alone; close's is the same. */ + async open(toolset: string, instance: string | undefined, options: Record | undefined, opts: { shown: boolean }): Promise<{ key: string; title?: string; content: unknown[]; change: ChangeSet; adopted: boolean; }> { const connection = this.live(); @@ -559,17 +568,17 @@ export class ToolsetController { key, ...(title ? { title } : {}), content: Array.isArray(res.content) ? res.content : [], - change: this.takeChange(res), + change: opts.shown ? this.takeChange(res) : this.changeOf(res), adopted: res.adopted === true, }; } /** toolsets/close (RFC §5.2). */ - async close(toolset: string, instance: string | undefined): Promise<{ content: unknown[]; change: ChangeSet }> { + async close(toolset: string, instance: string | undefined, opts: { shown: boolean }): Promise<{ content: unknown[]; change: ChangeSet }> { const connection = this.live(); const r = await connection.sendToolsetsClose({ toolset, ...(instance ? { instance } : {}) }, TOOLSET_REQUEST_TIMEOUT_MS); const res = isObj(r) ? r : {}; - return { content: Array.isArray(res.content) ? res.content : [], change: this.takeChange(res) }; + return { content: Array.isArray(res.content) ? res.content : [], change: opts.shown ? this.takeChange(res) : this.changeOf(res) }; } /** Open or close an undeclared group (RFC §5.4): the host's own act; the server isn't told. */ diff --git a/src/mcpl/toolsets-module.ts b/src/mcpl/toolsets-module.ts index 891daf799..419813f32 100644 --- a/src/mcpl/toolsets-module.ts +++ b/src/mcpl/toolsets-module.ts @@ -113,7 +113,7 @@ function checkSelectors(args: Record): void { /** A failure whose outcome is unknown, with how to find it out. */ export function withLookHint(serverId: string, text: string): string { const said = text.trimEnd(); - return `${said}${/[.!?]$/.test(said) ? '' : '.'} ${META_TOOL_NAMES.look} {server:"${serverId}"} asks it what's open.`; + return `${said}${/[.!?]$/.test(said) ? '' : '.'} ${META_TOOL_NAMES.look} {server:"${serverId}"} asks it what's open, or says it couldn't.`; } function requestFailure(serverId: string, did: string, unconfirmed: string, error: unknown): never { @@ -122,6 +122,9 @@ function requestFailure(serverId: string, did: string, unconfirmed: string, erro return fail(withLookHint(serverId, `${serverId} ${unconfirmed}: ${message}`)); } +/** A result's tool change, for whoever shows it with the result (ToolsetsModule's takerFor). */ +export type TakenChange = { toolsets: ToolsetController; cs: ChangeSet }; + const isObj = (v: unknown): v is Record => typeof v === 'object' && v !== null && !Array.isArray(v); type Block = { type: string; text?: string; [k: string]: unknown }; @@ -244,7 +247,14 @@ export function resolveAct(hub: ToolsetHub, input: unknown): ActTarget { export class ToolsetsModule implements Module { readonly name = TOOLSETS_MODULE; - constructor(private readonly hub: ToolsetHub) {} + /** `takerFor`: who shows a call's result to an agent, taking the tool + * change rendered into it; null when the result may reach none, and its + * change is left to a notice (RFC-014 §6.1: told once). Left out, every + * call's result is taken to reach the agent, as in 0.27.0. */ + constructor( + private readonly hub: ToolsetHub, + private readonly takerFor: (call: ToolCall) => ((taken: TakenChange) => void) | null = () => ({ toolsets, cs }) => toolsets.told(cs), + ) {} async start(_ctx: ModuleContext): Promise {} async stop(): Promise {} @@ -268,11 +278,12 @@ export class ToolsetsModule implements Module { const args = isObj(call.input) ? call.input : {}; try { checkSelectors(args); + const take = this.takerFor(call); switch (call.name) { - case 'open': return await this.open(args); + case 'open': return await this.open(args, take); case 'act': return this.act(args); - case 'look': return await this.look(args); - case 'close': return await this.close(args); + case 'look': return await this.look(args, take); + case 'close': return await this.close(args, take); default: return { success: false, isError: true, error: unknownToolMessage(this.name, call.name, META_TOOLS) }; } } catch (error) { @@ -288,13 +299,14 @@ export class ToolsetsModule implements Module { } /** A change set rendered without the key the caller already showed whole. */ - private renderOthers(c: ToolsetController, cs: ChangeSet, key: string): string { - return c.presence.render({ ...cs, changes: cs.changes.filter((ch) => ch.key !== key) }); + private renderOthers(c: ToolsetController, cs: ChangeSet, key: string, shown: boolean): string { + return c.presence.render({ ...cs, changes: cs.changes.filter((ch) => ch.key !== key) }, { mark: shown, describeAll: !shown }); } // ── the tools ── - private async open(args: Record): Promise { + private async open(args: Record, take: ((taken: TakenChange) => void) | null): Promise { + const shown = take !== null; let toolset = str(args.toolset); const c = toolset && !str(args.server) ? owner(this.hub, undefined, (x) => x.presence.isDeclared(toolset!) || x.presence.undeclaredGroups.includes(toolset!), `a toolset "${toolset}"`) @@ -313,7 +325,7 @@ export class ToolsetsModule implements Module { if (instance && !p.declared[toolset]!.instances) fail(`${c.serverId}/${toolset} has no instances: open it without one`); let opened: Awaited>; try { - opened = await c.open(toolset, instance, options); + opened = await c.open(toolset, instance, options, { shown }); } catch (error) { return requestFailure(c.serverId, `did not open ${toolset}`, `didn't confirm opening ${toolset}`, error); } @@ -322,15 +334,15 @@ export class ToolsetsModule implements Module { { type: 'text', text: `${p.label(opened.key)}${opened.title ? ` ${opened.title}` : ''}${opened.adopted ? ' (already open)' : ''}` }, ...asBlocks(opened.content), ]; - const tail = [...p.toolBlock(opened.key, this.toolsAt(c, opened.key), { headed: false })]; + const tail = [...p.toolBlock(opened.key, this.toolsAt(c, opened.key), { headed: false, describeAll: !shown, seen: shown ? undefined : new Set() })]; const gone = opened.change.changes.find((ch) => ch.key === opened.key)?.gone ?? []; if (gone.length) tail.push(`now unavailable: ${gone.join(', ')}`); - const others = this.renderOthers(c, opened.change, opened.key); + const others = this.renderOthers(c, opened.change, opened.key, shown); if (others) tail.push(others); const close = p.closeHint(opened.key); if (close) tail.push(`${close} closes it.`); blocks.push({ type: 'text', text: tail.join('\n') }); - c.told(opened.change); + take?.({ toolsets: c, cs: opened.change }); return blocksResult(blocks); } if (p.undeclaredGroups.includes(toolset)) { @@ -338,10 +350,12 @@ export class ToolsetsModule implements Module { if (instance || options) fail(`${c.serverId}/${toolset} takes no instance or options (the server doesn't declare it)`); const cs = c.setGroupOpen(toolset, true); const key = keyOf(toolset); - const lines = [`${p.label(key)} open.`, ...p.toolBlock(key, this.toolsAt(c, key), { headed: false })]; - const others = this.renderOthers(c, cs, key); + const lines = [`${p.label(key)} open.`, ...p.toolBlock(key, this.toolsAt(c, key), { headed: false, describeAll: !shown, seen: shown ? undefined : new Set() })]; + const others = this.renderOthers(c, cs, key, shown); if (others) lines.push(others); lines.push(`${p.closeHint(key)} closes it.`); + if (take) take({ toolsets: c, cs }); + else c.tell(cs); return textResult(lines.join('\n')); } return fail(`${c.serverId} has no toolset "${toolset}"${openable.length ? `; it has ${openable.join(', ')}` : ''}`); @@ -361,32 +375,42 @@ export class ToolsetsModule implements Module { } /** look restates the present: titles and visibility are re-read from the servers first (toolsets/list). */ - private async look(args: Record): Promise { + private async look(args: Record, take: ((taken: TakenChange) => void) | null): Promise { const server = str(args.server); const targets = server ? [this.hub.get(server)].filter((c): c is ToolsetController => !!c && c.presents) : this.hub.presenting(); - const read = (await Promise.all(targets.map(async (c) => ({ c, cs: await c.refreshOpen().catch(() => null) })))) - .filter((r): r is { c: ToolsetController; cs: ChangeSet } => r.cs !== null); + const shown = take !== null; + const reads = await Promise.all(targets.map(async (c) => ({ c, cs: await c.refreshOpen({ shown }).catch(() => null) }))); + const read = reads.filter((r): r is { c: ToolsetController; cs: ChangeSet } => r.cs !== null); + const unread = reads.filter((r) => r.cs === null).map(({ c }) => `${c.serverId} couldn't be asked just now: this is what the host last knew of it.`); + const giveBack = () => { + if (shown) for (const { c, cs } of read) c.tell(cs); + }; let result: ToolResult; try { - result = this.lookNow(args, server, str(args.tool)); + result = this.lookNow(args, server, str(args.tool), shown); } catch (error) { - for (const { c, cs } of read) c.tell(cs); + giveBack(); + if (error instanceof MetaError && unread.length) fail([error.message, ...unread].join('\n')); throw error; } - const restates = !str(args.tool) && !str(args.toolset) && !str(args.instance); - if (result.isError || typeof result.data !== 'string' || !restates) { - for (const { c, cs } of read) c.tell(cs); + if (result.isError || typeof result.data !== 'string') { + giveBack(); return result; } - const lines = read.flatMap(({ c, cs }) => { - const shown = [...cs.suggest.map((s) => c.presence.renderSuggestion(s)), ...c.presence.untoldNotes(cs), ...cs.notes]; - c.told(cs); - return shown; - }); + const restates = !str(args.tool) && !str(args.toolset) && !str(args.instance); + if (!restates) giveBack(); + const lines = restates + ? read.flatMap(({ c, cs }) => { + const said = [...cs.suggest.map((s) => c.presence.renderSuggestion(s)), ...c.presence.untoldNotes(cs), ...cs.notes]; + take?.({ toolsets: c, cs }); + return said; + }) + : []; + lines.push(...unread); return lines.length ? textResult(`${result.data}\n${lines.join('\n')}`) : result; } - private lookNow(args: Record, server: string | undefined, tool: string | undefined): ToolResult { + private lookNow(args: Record, server: string | undefined, tool: string | undefined, mark: boolean): ToolResult { if (!server) { if (tool) return this.lookTool(owner(this.hub, undefined, (c) => !!c.presence.tool(tool), `a tool "${tool}"`), tool, str(args.instance)); const ts = str(args.toolset); @@ -397,11 +421,11 @@ export class ToolsetsModule implements Module { (x) => (ts ? x.presence.isDeclared(ts) || x.presence.undeclaredGroups.includes(ts) : true) && (!inst || x.presence.openRecords().some((o) => o.instance === inst)), ts ? `a toolset "${ts}"${inst ? ` with instance "${inst}"` : ''}` : `an open instance "${inst}"`, ); - return this.lookNow(args, c.serverId, tool); + return this.lookNow(args, c.serverId, tool, mark); } const all = this.hub.presenting(); - if (!all.length) return textResult('No MCPL server presents toolsets or dynamic tools now; every tool is in your tool list.'); - return textResult(all.map((c) => c.presence.catalog()).join('\n\n')); + if (!all.length) return textResult('No MCPL server presents toolsets or dynamic tools now; MCPL tools are called directly, by name.'); + return textResult(all.map((c) => c.presence.catalog({ mark })).join('\n\n')); } const c = pick(this.hub, server); if (tool) return this.lookTool(c, tool, str(args.instance)); @@ -446,7 +470,8 @@ export class ToolsetsModule implements Module { return textResult(`${renderToolDefinition(def)}\n${p.toolStatus(tool)}`); } - private async close(args: Record): Promise { + private async close(args: Record, take: ((taken: TakenChange) => void) | null): Promise { + const shown = take !== null; const named = str(args.toolset); const c = named && !str(args.server) ? owner(this.hub, undefined, (x) => x.presence.isOpen(named, str(args.instance)) || x.presence.openRecords(named).length > 0, `an open toolset "${named}"`) @@ -477,21 +502,24 @@ export class ToolsetsModule implements Module { } let closed: Awaited>; try { - closed = await c.close(toolset!, instance); + closed = await c.close(toolset!, instance, { shown }); } catch (error) { const what = `${toolset}${instance ? `:${instance}` : ''}`; return requestFailure(c.serverId, `did not close ${what}`, `didn't confirm closing ${what}`, error); } const key = keyOf(toolset!, instance); - const change = p.render(closed.change) || `${p.label(key)} closed.`; - c.told(closed.change); + const change = p.render(closed.change, { mark: shown, describeAll: !shown }) || `${p.label(key)} closed.`; + take?.({ toolsets: c, cs: closed.change }); return blocksResult([...asBlocks(closed.content), { type: 'text', text: change }]); } if (p.undeclaredGroups.includes(toolset!)) { if (instance) fail(`${c.serverId}/${toolset} takes no instance (the server doesn't declare it)`); if (!p.isOpen(toolset!)) fail(`${c.serverId}/${toolset} is not open`); const cs = c.setGroupOpen(toolset!, false); - return textResult(p.render(cs) || `${p.label(keyOf(toolset!))} closed.`); + const text = p.render(cs, { mark: shown, describeAll: !shown }) || `${p.label(keyOf(toolset!))} closed.`; + if (take) take({ toolsets: c, cs }); + else c.tell(cs); + return textResult(text); } return fail(`${c.serverId} has no toolset "${toolset}"`); } diff --git a/test/helpers/toolset-servers.ts b/test/helpers/toolset-servers.ts index 38c704401..476bde2e9 100644 --- a/test/helpers/toolset-servers.ts +++ b/test/helpers/toolset-servers.ts @@ -19,6 +19,7 @@ import { ToolsetServer, type ToolsetServerSpec } from '@animalabs/mcpl-core/tool import { AgentFramework } from '../../src/index.js'; import type { McplServerConfig } from '../../src/mcpl/types.js'; +import type { FrameworkConfig } from '../../src/types/framework.js'; import type { Module, ModuleContext, ProcessEvent, ProcessState, EventResponse, ToolDefinition, ToolResult } from '../../src/index.js'; import { MockMembrane } from './mock-membrane.js'; @@ -169,8 +170,8 @@ class Quiet implements Module { } } -/** A framework with one agent and the given MCPL servers. */ -export async function makeFramework(servers: McplServerConfig[]) { +/** A framework with one agent and the given MCPL servers; `options` replaces any of its config. */ +export async function makeFramework(servers: McplServerConfig[], options: Partial = {}) { const dir = mkdtempSync(join(tmpdir(), 'af-toolsets-')); const membrane = new MockMembrane(); const framework = await AgentFramework.create({ @@ -179,6 +180,7 @@ export async function makeFramework(servers: McplServerConfig[]) { agents: [{ name: 'scout', model: 'test-model', systemPrompt: 'You are scout.' }], modules: [new Quiet()], mcplServers: servers, + ...options, }); await framework.start(); return { diff --git a/test/mcpl-tool-hints.test.ts b/test/mcpl-tool-hints.test.ts index d22b65a41..0361330de 100644 --- a/test/mcpl-tool-hints.test.ts +++ b/test/mcpl-tool-hints.test.ts @@ -492,7 +492,7 @@ test('act and a direct call take one dispatch path: state exchange, lifecycle, r assert.match(results, /play ran/); assert.match(results, /now unavailable: play/); await nextMacrotask(); - assert.ok(!windowText(fw.framework).some((t) => t.includes('tool changes')), 'told once, in the result'); + assert.ok(!windowText(fw.framework).some((t) => t.includes('tool changes') && t.includes('now unavailable: play')), 'told once, in the result'); } finally { await fw.stop(); await server.close(); @@ -600,7 +600,7 @@ test('an open or close whose outcome the host can\'t know is reported as unconfi const r = await callTool(dropped.fw.framework, 'toolsets--open', { server: 'arc', toolset: 'build' }); assert.ok(r.isError); assert.match(resultText(r), /^arc didn't confirm opening build: The connection to the "arc" server closed before it answered/); - assert.match(resultText(r), /toolsets--look \{server:"arc"\} asks it what's open\.$/); + assert.match(resultText(r), /toolsets--look \{server:"arc"\} asks it what's open, or says it couldn't\.$/); const again = await callTool(dropped.fw.framework, 'toolsets--open', { server: 'arc', toolset: 'build' }); assert.ok(again.isError); assert.equal(resultText(again), 'arc did not open build: arc is not connected right now; try again later', 'with the connection gone, an open is known not sent'); @@ -619,7 +619,7 @@ test('an open or close whose outcome the host can\'t know is reported as unconfi await callTool(refused.fw.framework, 'toolsets--open', { server: 'arc', toolset: 'build' }); const r = await callTool(refused.fw.framework, 'toolsets--close', { server: 'arc', toolset: 'build' }); assert.ok(r.isError); - assert.match(resultText(r), /^arc didn't confirm closing build: .*the board is locked\. toolsets--look \{server:"arc"\} asks it what's open\.$/); + assert.match(resultText(r), /^arc didn't confirm closing build: .*the board is locked\. toolsets--look \{server:"arc"\} asks it what's open, or says it couldn't\.$/); } finally { await refused.done(); } }); @@ -693,10 +693,12 @@ test('an ended record is told once it reaches the agent: a notice not written le if (msg.method === 'toolsets/list') state.ended = [{ id: 'e1', toolset: 'game', instance: 'g1', reason: 'the game is over' }]; return false; }, { disabledCapabilities: ['pushEvents'] }); + const agentLook = async () => resultText((await t.fw.framework.puppetToolCall('scout', 'toolsets--look', { server: 'arc' })).result); try { - assert.match(await look(t.fw), /\[arc\/game:g1\] ended: the game is over\./, 'look tells it'); + assert.match(await look(t.fw), /\[arc\/game:g1\] ended: the game is over\./, 'a host\'s own look shows it to its caller'); assert.ok(!notices(t.fw).length, 'without pushEvents no notice was written'); - assert.doesNotMatch(await look(t.fw), /ended: the game is over/, 'and only once'); + assert.match(await agentLook(), /\[arc\/game:g1\] ended: the game is over\./, 'so the agent\'s own look still tells it'); + assert.doesNotMatch(await agentLook(), /ended: the game is over/, 'and only once'); } finally { await t.done(); } }); @@ -1271,6 +1273,26 @@ test('close with nothing named, after a group the host opened was declared, says assert.match(String(result.error), /nothing is open on arc/); }); +test('a ToolsetsModule built as 0.27.0 built it, ToolsetsModule(hub), takes the change its look shows as told', async () => { + const statement = { open: [], _meta: { 'mcpl/hints': { rev: 1, tools: [{ name: 'peek', visible: true }] } } }; + const look = async (module: (hub: ToolsetHub) => ToolsetsModule) => { + const { hub, told } = tappedController(() => true, TOOLS, { + toolsetsList: (r) => { + r({ kind: 'result', method: 'toolsets/list', params: {}, result: statement, awaited: true }); + return statement; + }, + }); + const looked = resultText(await module(hub).handleToolCall({ id: 'l1', name: 'look', input: {} } as never)); + await nextMacrotask(); + return { looked, told }; + }; + const built = await look((hub) => new ToolsetsModule(hub)); + assert.match(built.looked, /peek\(\)/); + assert.equal(built.told.length, 0, 'not told again as a notice'); + const unshown = await look((hub) => new ToolsetsModule(hub, () => null)); + assert.equal(unshown.told.length, 1, 'a look whose result reaches no agent leaves it to a notice'); +}); + test('a tool a recovery\'s list puts in a toolset leaves the model\'s tool list without a further list change', async () => { const tools: Json[] = TOOLS.map((x) => ({ ...x })); const t = await setup(() => false, {}, ADVERT, tools); @@ -1328,7 +1350,7 @@ test('a change look hands back is told before a change read after it', async () return statement; }, }); - const cs = await c.refreshOpen(); + const cs = await c.refreshOpen({ shown: true }); readCall(callResult(2, [{ name: 'peek', visible: false }])); c.tell(cs!); await nextMacrotask(); @@ -1490,3 +1512,47 @@ test('look at a toolset whose options a server nests very deep still answers', a const looked = await new ToolsetsModule(hub).handleToolCall({ id: 'l2', name: 'look', input: { server: 'arc', toolset: 'maze' } } as never); assert.ok(!looked.isError, String(looked.error)); }); + +test('look says when it couldn\'t ask a server, and shows what the host last knew of it', async () => { + let listing = true; + const t = await setup((msg, peer) => { + if (msg.method !== 'toolsets/list' || listing) return false; + peer.error(msg.id, -32000, 'busy'); + return true; + }); + try { + listing = false; + const text = await look(t.fw); + assert.match(text, /^arc\n {2}nothing open/); + assert.match(text, /\narc couldn't be asked just now: this is what the host last knew of it\.$/); + const refused = await callTool(t.fw.framework, 'toolsets--look', { server: 'arc', instance: 'g1' }); + assert.ok(refused.isError); + assert.equal(resultText(refused), 'no open instance "g1" on arc; no instance is open there\narc couldn\'t be asked just now: this is what the host last knew of it.'); + } finally { await t.done(); } +}); + +test('an open\'s tool change goes with its result where the agent reads it, and as a notice for a host\'s own open', async () => { + const opens: Handler = (msg, peer, state) => { + if (msg.method !== 'toolsets/open') return false; + state.open = [{ toolset: 'build', title: 'B' }]; + peer.reply(msg.id, { toolset: 'build', title: 'B', _meta: { 'mcpl/hints': state.hint([{ name: 'place', visible: true }]) } }); + return true; + }; + const agent = await setup(opens); + try { + const { toolUseId } = await agent.fw.framework.puppetToolCall('scout', 'toolsets--open', { server: 'arc', toolset: 'build' }); + const stored = agent.fw.framework.getAgent('scout')!.getContextManager().getAllMessages() + .flatMap((m) => m.content as unknown as Array>) + .find((b) => b.type === 'tool_result' && b.toolUseId === toolUseId); + assert.match(JSON.stringify(stored?.content ?? null), /place\(x\?: int\) — Place a block\./, 'the stored result holds the change'); + await nextMacrotask(); + assert.equal(notices(agent.fw).length, 0, 'the puppet\'s stored result told it'); + } finally { await agent.done(); } + + const host = await setup(opens); + try { + const r = await callTool(host.fw.framework, 'toolsets--open', { server: 'arc', toolset: 'build' }); + assert.match(resultText(r), /place\(x\?: int\) — Place a block\./, 'the caller reads the tools it opened'); + await waitFor(() => notices(host.fw).some((x) => x.includes('[arc/build] B') && x.includes('place(x?: int) — Place a block.')), 'the agent told as a notice'); + } finally { await host.done(); } +}); diff --git a/test/mcpl-toolsets-e2e.test.ts b/test/mcpl-toolsets-e2e.test.ts index 1da73f190..1b23814a2 100644 --- a/test/mcpl-toolsets-e2e.test.ts +++ b/test/mcpl-toolsets-e2e.test.ts @@ -161,13 +161,11 @@ describe('RFC-014 host, end to end against the reference ToolsetServer', () => { assert.ok(!act.isError, resultText(act)); }); - it('close reports what closing meant, and the toolset tools go', async () => { + it('close reports what closing meant; a host\'s own close, whose result the agent may not see, is told as a notice too', async () => { await callTool(fw.framework, 'toolsets--open', { server: 'arcade', toolset: 'build' }); const closed = await callTool(fw.framework, 'toolsets--close', { server: 'arcade', toolset: 'build' }); assert.match(resultText(closed), /\[arcade\/build\] closed/); - await new Promise((r) => setImmediate(r)); - // Nothing of it reached the window as a notice: the results carried it. - assert.ok(!windowText(fw.framework).some((t) => t.includes('tool changes') && t.includes('[arcade/build]'))); + await waitFor(() => windowText(fw.framework).some((t) => t.includes('tool changes') && t.includes('[arcade/build] closed')), 'the close told'); }); }); diff --git a/test/mcpl-toolsets-wiring.test.ts b/test/mcpl-toolsets-wiring.test.ts index 7e45620f8..6a34ec1d5 100644 --- a/test/mcpl-toolsets-wiring.test.ts +++ b/test/mcpl-toolsets-wiring.test.ts @@ -3,7 +3,9 @@ import assert from 'node:assert/strict'; import type { Module } from '../src/types/index.js'; import type { McplServerConfig } from '../src/mcpl/types.js'; -import { startScriptedServer, makeFramework, waitFor, type Json } from './helpers/toolset-servers.js'; +import { startScriptedServer, makeFramework, waitFor, callTool, resultText, windowText, type Json } from './helpers/toolset-servers.js'; +import { createMockResponse } from './helpers/mock-membrane.js'; +import type { FrameworkConfig } from '../src/types/framework.js'; const ADVERT: Json = { toolHints: true, @@ -58,3 +60,267 @@ test('a stop while a toolHints server is still connecting ends the connect, whic await server.close(); } }); + +const PEEK: Json = { name: 'peek', description: 'Peek around.', inputSchema: { type: 'object', properties: {} }, _meta: { 'mcpl/presence': 'dynamic', 'mcpl/visible': false } }; +const EVENTS: Json = { 'arc.events': { description: 'events', uses: ['pushEvents'] } }; + +async function hintingServer(answer: Json, { pushEvents = true, listing = (): boolean => true, peek = PEEK } = {}) { + let rev = 0; + let statement: Json[] = []; + const showPeek = () => { + statement = [{ name: 'peek', visible: true }]; + return { rev: ++rev, tools: statement }; + }; + const server = await startScriptedServer({ + advert: { ...ADVERT, pushEvents, featureSets: { ...(ADVERT.featureSets as Json), ...(pushEvents ? EVENTS : {}) } }, + tools: [STATUS, peek], + listRev: () => rev, + on: (msg, peer) => { + if (msg.method === 'toolsets/list' && !listing()) { peer.error(msg.id, -32000, 'busy'); return; } + if (msg.method === 'toolsets/list') { peer.reply(msg.id, { open: [], _meta: { 'mcpl/hints': { rev: ++rev, tools: statement } } }); return; } + if (msg.method === 'tools/call') { peer.reply(msg.id, { ...answer, _meta: { 'mcpl/hints': showPeek() } }); return; } + if (msg.id !== undefined) peer.error(msg.id, -32601, 'Method not found'); + }, + }); + return { server, showPeek }; +} + +async function connectedTo(server: Awaited>, options: Partial = {}) { + const fw = await makeFramework([{ id: 'arc', toolPrefix: 'arc', url: server.url, toolHints: true }], options); + await waitFor(() => fw.framework.getAllTools().some((t) => t.name === 'arc--status'), 'static tools listed'); + await waitFor(() => server.received.some((m) => m.method === 'toolsets/list'), 'toolsets/list after connecting'); + return fw; +} + +type Fw = Awaited>; + +async function modelCalls(fw: Fw, blocks: Json[]): Promise { + fw.membrane.pushResponse(createMockResponse(blocks as never, 'tool_use')); + fw.membrane.pushResponse(createMockResponse([{ type: 'text', text: 'turn over' }])); + fw.framework.pushEvent({ type: 'external-message', source: 'test', content: [{ type: 'text', text: 'go' }], metadata: {}, triggerInference: true } as never); + await waitFor(() => windowText(fw.framework).some((x) => x.includes('turn over')), 'the turn ended'); +} + +function resultFor(fw: Fw, toolUseId: string): string { + const results = (fw.membrane.lastStream?.receivedToolResults ?? []).flat() as Array<{ toolUseId: string; content: unknown }>; + const found = results.find((r) => r.toolUseId === toolUseId); + assert.ok(found, `a result for ${toolUseId}`); + return typeof found.content === 'string' ? found.content : JSON.stringify(found.content); +} + +const notices = (fw: Fw) => windowText(fw.framework).filter((x) => x.includes('tool changes:')); +const nextMacrotask = () => new Promise((r) => setImmediate(r)); + +function storedResult(fw: Fw, toolUseId: string): string { + const stored = fw.framework.getAgent('scout')!.getContextManager().getAllMessages() + .flatMap((m) => m.content as unknown as Array>) + .find((b) => b.type === 'tool_result' && b.toolUseId === toolUseId); + assert.ok(stored, 'the pair is stored'); + return JSON.stringify(stored.content); +} + +test('a model\'s call whose result is structured content alone shows that content, then the tool change its hints made', async () => { + const { server } = await hintingServer({ content: [], structuredContent: { score: 42 } }); + const fw = await connectedTo(server); + try { + await modelCalls(fw, [{ type: 'tool_use', id: 'toolu_status', name: 'arc--status', input: {} }]); + const result = resultFor(fw, 'toolu_status'); + assert.match(result, /"score":42/); + assert.match(result, /peek\(\)/); + assert.ok(result.indexOf('"score":42') < result.indexOf('peek()'), 'the change after the content'); + assert.equal(notices(fw).length, 0, 'not told again as a notice'); + } finally { + await fw.stop(); + await server.close(); + } +}); + +test('a script\'s call whose result carries a tool change leaves the change to a notice: the script\'s result doesn\'t hold it', async () => { + const { server } = await hintingServer({ content: [{ type: 'text', text: 'all quiet' }] }); + const fw = await connectedTo(server, { codeExecution: { enabled: true } }); + try { + const code = 'const r = await tools["arc--status"]({}); text(JSON.stringify(r.content));'; + await modelCalls(fw, [{ type: 'tool_use', id: 'toolu_script', name: 'code_execution', input: { code } }]); + const script = resultFor(fw, 'toolu_script'); + assert.match(script, /all quiet/); + assert.doesNotMatch(script, /peek/, 'the script\'s result doesn\'t hold the change'); + await waitFor(() => notices(fw).some((x) => x.includes('peek()')), 'the change told as a notice'); + } finally { + await fw.stop(); + await server.close(); + } +}); + +test('the operator\'s puppet call stores its result with the tool change its hints made, and the change isn\'t told again', async () => { + const { server } = await hintingServer({ content: [{ type: 'text', text: 'all quiet' }] }); + const fw = await connectedTo(server); + try { + const { toolUseId } = await fw.framework.puppetToolCall('scout', 'arc--status', {}); + const text = storedResult(fw, toolUseId); + assert.match(text, /all quiet/); + assert.match(text, /peek\(\)/); + await nextMacrotask(); + assert.equal(notices(fw).length, 0, 'not told again as a notice'); + } finally { + await fw.stop(); + await server.close(); + } +}); + +test('the puppet stores a result that is structured content alone as that content\'s JSON, then the tool change', async () => { + const { server } = await hintingServer({ content: [], structuredContent: { score: 42 } }); + const fw = await connectedTo(server); + try { + const { toolUseId } = await fw.framework.puppetToolCall('scout', 'arc--status', {}); + const text = storedResult(fw, toolUseId); + assert.match(text, /\\"score\\":42/); + assert.match(text, /peek\(\)/); + assert.ok(text.indexOf('score') < text.indexOf('peek()'), 'the change after the content'); + } finally { + await fw.stop(); + await server.close(); + } +}); + +test('the puppet\'s toolsets--act stores the dynamic tool\'s result with the tool change its call made, and the change isn\'t told again', async () => { + const { server } = await hintingServer({ content: [{ type: 'text', text: 'peeked' }] }); + const fw = await connectedTo(server); + try { + const { toolUseId } = await fw.framework.puppetToolCall('scout', 'toolsets--act', { server: 'arc', tool: 'peek', args: {} }); + const text = storedResult(fw, toolUseId); + assert.match(text, /peeked/); + assert.match(text, /peek\(\)/); + await nextMacrotask(); + assert.equal(notices(fw).length, 0, 'not told again as a notice'); + } finally { + await fw.stop(); + await server.close(); + } +}); + +test('a puppet call whose pair can\'t be stored tells the tool change its result carried, as a notice', async () => { + const { server } = await hintingServer({ content: [{ type: 'text', text: 'all quiet' }] }); + const fw = await connectedTo(server); + try { + const cm = fw.framework.getAgent('scout')!.getContextManager(); + const add = cm.addMessage.bind(cm); + let refused = false; + cm.addMessage = ((...args: Parameters) => { + if (!refused) { + refused = true; + throw new Error('the store refused the write'); + } + return add(...args); + }) as typeof cm.addMessage; + await assert.rejects(fw.framework.puppetToolCall('scout', 'arc--status', {}), /the store refused the write/); + await waitFor(() => notices(fw).some((x) => x.includes('peek()')), 'the change told as a notice'); + } finally { + await fw.stop(); + await server.close(); + } +}); + +test('a model\'s call whose result, with its tool change after it, would pass the inline cap keeps its result whole and tells the change as a notice', async () => { + const content = 'x'.repeat(990); + const { server } = await hintingServer({ content: [{ type: 'text', text: content }] }); + const fw = await connectedTo(server, { toolResultInlineMaxChars: 1000 }); + try { + await modelCalls(fw, [{ type: 'tool_use', id: 'toolu_long', name: 'arc--status', input: {} }]); + assert.equal(resultFor(fw, 'toolu_long'), content, 'the result whole, without the change'); + await waitFor(() => notices(fw).some((x) => x.includes('peek()')), 'the change told as a notice'); + } finally { + await fw.stop(); + await server.close(); + } +}); + +test('a model\'s call whose tool change fits goes with its result even where a definition holds a lone surrogate', async () => { + const { server } = await hintingServer({ content: [{ type: 'text', text: 'all quiet' }] }, { peek: { ...PEEK, description: 'Peek \uD800 around.' } }); + const fw = await connectedTo(server); + try { + await modelCalls(fw, [{ type: 'tool_use', id: 'toolu_odd', name: 'arc--status', input: {} }]); + assert.match(resultFor(fw, 'toolu_odd'), /all quiet[\s\S]*peek\(\)/); + await nextMacrotask(); + assert.equal(notices(fw).length, 0, 'not told as a notice'); + } finally { + await fw.stop(); + await server.close(); + } +}); + +test('a model\'s call whose result the host can\'t read still tells the tool change it carried, as a notice', async () => { + let rev = 0; + const nestedPastStringify = `${'['.repeat(100_000)}${']'.repeat(100_000)}`; + const server = await startScriptedServer({ + advert: { ...ADVERT, pushEvents: true, featureSets: { ...(ADVERT.featureSets as Json), ...EVENTS } }, + tools: [STATUS, PEEK], + listRev: () => rev, + on: (msg, peer) => { + if (msg.method === 'toolsets/list') { peer.reply(msg.id, { open: [], _meta: { 'mcpl/hints': { rev: ++rev, tools: [] } } }); return; } + if (msg.method === 'tools/call') { + const meta = JSON.stringify({ 'mcpl/hints': { rev: ++rev, tools: [{ name: 'peek', visible: true }] } }); + peer.ws.send(`{"jsonrpc":"2.0","id":${JSON.stringify(msg.id)},"result":{"content":[],"structuredContent":${nestedPastStringify},"_meta":${meta}}}`); + return; + } + if (msg.id !== undefined) peer.error(msg.id, -32601, 'Method not found'); + }, + }); + const fw = await connectedTo(server); + try { + await modelCalls(fw, [{ type: 'tool_use', id: 'toolu_deep', name: 'arc--status', input: {} }]); + assert.doesNotMatch(resultFor(fw, 'toolu_deep'), /peek/, 'the failed call\'s result doesn\'t hold it'); + await waitFor(() => notices(fw).some((x) => x.includes('peek()')), 'the change told as a notice'); + } finally { + await fw.stop(); + await server.close(); + } +}); + +test('a tool change the host can\'t tell for want of an agent is logged, as one it can\'t tell for want of pushEvents is', async () => { + const { server, showPeek } = await hintingServer({ content: [] }); + const fw = await connectedTo(server, { agents: [] }); + const logged: string[] = []; + const error = console.error; + console.error = (...args: unknown[]) => { logged.push(args.map(String).join(' ')); }; + try { + server.peer.notify('tools/hints', showPeek()); + await waitFor(() => logged.some((line) => line.includes('arc: a tool change was not told')), 'the drop logged'); + assert.ok(logged.some((line) => /arc: a tool change was not told \(no agent is registered to tell\); toolsets--look shows the state it left/.test(line))); + } finally { + console.error = error; + await fw.stop(); + await server.close(); + } +}); + +test('a tool change with no result to ride on is a notice only where pushEvents is granted, and a notice starts no turn', async () => { + const granted = await hintingServer({ content: [] }); + const withGrant = await connectedTo(granted.server); + try { + const requests = withGrant.membrane.calls.length; + granted.server.peer.notify('tools/hints', granted.showPeek()); + await waitFor(() => notices(withGrant).some((x) => x.includes('peek()')), 'the notice written'); + const scheduler = withGrant.framework as unknown as { pendingRequests: unknown[] }; + assert.equal(scheduler.pendingRequests.length, 0, 'no inference requested'); + assert.equal(withGrant.framework.getAgent('scout')!.state.status, 'idle', 'no turn running'); + assert.equal(withGrant.membrane.calls.length, requests, 'no request sent: the notice started no turn'); + } finally { + await withGrant.stop(); + await granted.server.close(); + } + + let listing = true; + const refused = await hintingServer({ content: [] }, { pushEvents: false, listing: () => listing }); + const withoutGrant = await connectedTo(refused.server); + try { + listing = false; + refused.server.peer.notify('tools/hints', refused.showPeek()); + const looked = resultText(await callTool(withoutGrant.framework, 'toolsets--look', { server: 'arc' })); + assert.match(looked, /arc couldn't be asked just now/, 'look didn\'t re-read the server\'s state'); + assert.match(looked, /peek\(/, 'look shows the change the hint made'); + assert.equal(notices(withoutGrant).length, 0, 'nothing written to the conversation'); + } finally { + await withoutGrant.stop(); + await refused.server.close(); + } +}); From 0904fbf06bab1d66152ca6fbc95fc092893096ef Mon Sep 17 00:00:00 2001 From: Octavia-2412 Date: Sun, 25 Oct 2026 19:01:58 -0400 Subject: [PATCH 06/46] fix(mcpl): a hinted request is remembered without its arguments hintedRequests kept each pending hinted request's whole params, up to 1,000 entries, until it was answered or the transport ended. A run at b9a7bc83 with 1,200 timed-out tools/call requests of 10 KB arguments each held 10,041,000 characters of params at the cap. The read of a late result uses only its method, and an open's or close's toolset and instance (readResult), so that is all that's kept now; a call's arguments are never held. With tools/list out of HINTED_METHODS (since "tool hints follow the server's statements, ...") no cursor is needed either. Test: a close answered after its timeout (the connection's own request-local timeout, 300 ms) still closes the instance it named, and is told. It passes at 8aaee1ca, which kept every param, and fails once the toolset or the instance isn't kept. That a call's arguments aren't kept is memory no caller observes. Co-Authored-By: Claude Opus 5.5 --- src/mcpl/server-connection.ts | 16 +++++++++++++--- test/mcpl-tool-hints.test.ts | 21 +++++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/src/mcpl/server-connection.ts b/src/mcpl/server-connection.ts index 1162aacd9..4bc12a39d 100644 --- a/src/mcpl/server-connection.ts +++ b/src/mcpl/server-connection.ts @@ -256,6 +256,14 @@ export type HintRead = /** Requests whose results can carry hint state, remembered for a late answer (RFC-014 §6.1). */ const HINTED_METHODS: ReadonlySet = new Set(['tools/call', 'toolsets/open', 'toolsets/close', 'toolsets/list']); +function hintedParams(method: string, params: Record): { toolset?: string; instance?: string } { + if (method !== 'toolsets/open' && method !== 'toolsets/close') return {}; + return { + ...(typeof params.toolset === 'string' ? { toolset: params.toolset } : {}), + ...(typeof params.instance === 'string' ? { instance: params.instance } : {}), + }; +} + /** * A handshake that failed, and whose cleanup couldn't confirm that the child * exited. The transport goes to whoever owns the launch. @@ -465,9 +473,11 @@ export class McplServerConnection extends EventEmitter { */ private readTap: ((read: HintRead, epoch: number) => void) | null = null; /** The hinted requests sent on this transport, by id, kept past a timeout - * so a late result is still tapped; bounded. Cleared at each boundary, + * so a late result is still tapped: each one's method, and an open's or + * close's toolset and instance, which are all the read of its result + * uses; never a call's arguments. Bounded. Cleared at each boundary, * since the next transport numbers its requests afresh. */ - private hintedRequests = new Map }>(); + private hintedRequests = new Map(); /** Install (or, with null, remove) the RFC-014 read tap (see readTap). */ setReadTap(tap: ((read: HintRead, epoch: number) => void) | null): void { @@ -2243,7 +2253,7 @@ export class McplServerConnection extends EventEmitter { // RFC-014: a request whose result can carry hints is remembered past // its timeout, so a late result's hints still apply (readTap). if (this.readTap && HINTED_METHODS.has(method)) { - this.hintedRequests.set(id, { method, params }); + this.hintedRequests.set(id, { method, params: hintedParams(method, params) }); if (this.hintedRequests.size > 1000) this.hintedRequests.delete(this.hintedRequests.keys().next().value!); } try { diff --git a/test/mcpl-tool-hints.test.ts b/test/mcpl-tool-hints.test.ts index 0361330de..fe03cbccc 100644 --- a/test/mcpl-tool-hints.test.ts +++ b/test/mcpl-tool-hints.test.ts @@ -308,6 +308,27 @@ test('vector 28: the hints of a result that arrives after its timeout are applie } finally { await t.done(); } }); +test('a close answered after its timeout still closes the instance it named, and is told', async () => { + let held: { id: unknown; peer: ScriptedPeer } | null = null; + const t = await setup((msg, peer, state) => { + if (msg.method === 'toolsets/close') { held = { id: msg.id, peer }; return true; } + if (msg.method !== 'toolsets/open') return false; + state.open.push({ toolset: 'game', instance: 'g1' }); + peer.reply(msg.id, { toolset: 'game', instance: 'g1', _meta: { 'mcpl/hints': state.hint([{ name: 'play', instance: 'g1', visible: true }]) } }); + return true; + }); + try { + await callTool(t.fw.framework, 'toolsets--open', { server: 'arc', toolset: 'game', instance: 'g1' }); + const connection = (t.fw.framework as unknown as { mcplServerRegistry: { getServer(id: string): { sendToolsetsClose(p: Json, ms: number): Promise } } }) + .mcplServerRegistry.getServer('arc'); + await assert.rejects(connection.sendToolsetsClose({ toolset: 'game', instance: 'g1' }, 300)); + assert.ok(held, 'the close reached the server'); + const h = held as unknown as { id: unknown; peer: ScriptedPeer }; + h.peer.reply(h.id, { content: [] }); + await waitFor(() => notices(t.fw).some((x) => x.includes('[arc/game:g1] closed.')), 'the late close told'); + } finally { await t.done(); } +}); + test('vector 30: a duplicate eventId\'s hints are ignored', async () => { const t = await setup(); try { From da15eded1f2775c09babd8f904f472e5aa2ac93d Mon Sep 17 00:00:00 2001 From: Octavia-2412 Date: Sun, 25 Oct 2026 19:03:17 -0400 Subject: [PATCH 07/46] fix(mcpl): the capability mask leaves RFC-014's declarations alone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit toolsetDeclarationsOf's comment says the host's capability mask never applies to RFC-014's members (toolHints, toolsets, surface), since they're declarations, not §6.2 capabilities; hints run from the raw advertisement. But maskNegotiatedCapabilities masked every member but version and featureSets, so a server config whose enabledCapabilities named none of them logged them as "capabilities masked by host config" and recorded them as dropped, while the hints went on regardless. They join version and featureSets among the keys the mask passes through, and enabledCapabilities' doc says so. A host turns hints off per server with toolHints: false. Test: with enabledCapabilities: ['pushEvents'], nothing of RFC-014 is logged as masked, and the dynamic tool stays out of the tool list with the meta-tools offered. At 8aaee1ca the log read "capabilities masked by host config: toolHints, toolsets.build.title, ..., surface". Co-Authored-By: Claude Opus 5.5 --- src/mcpl/capability-mask.ts | 5 ++-- src/mcpl/types.ts | 4 +++- test/mcpl-toolsets-wiring.test.ts | 38 +++++++++++++++++++++++++++++-- 3 files changed, 42 insertions(+), 5 deletions(-) diff --git a/src/mcpl/capability-mask.ts b/src/mcpl/capability-mask.ts index 986765f60..ab6f52484 100644 --- a/src/mcpl/capability-mask.ts +++ b/src/mcpl/capability-mask.ts @@ -81,8 +81,9 @@ function anyMatches(patterns: string[], path: string): boolean { return patterns.some((pattern) => patternMatchesPath(pattern, path)); } -/** Keys that are negotiation metadata, not grantable capabilities. */ -const UNMASKABLE_KEYS = new Set(['version', 'featureSets']); +/** Keys that are negotiation metadata, or RFC-014 declarations (toolHints, + * toolsets, surface), not grantable capabilities. */ +const UNMASKABLE_KEYS = new Set(['version', 'featureSets', 'toolHints', 'toolsets', 'surface']); /** * Interior object paths whose object value is itself the capability, with diff --git a/src/mcpl/types.ts b/src/mcpl/types.ts index 4ae4215e4..caa6ce4b2 100644 --- a/src/mcpl/types.ts +++ b/src/mcpl/types.ts @@ -384,7 +384,9 @@ export interface McplServerConfig { * * `disabledCapabilities` takes precedence on conflict. `version` and * `featureSets` are not maskable here — feature sets have their own - * enable/disable knobs above. + * enable/disable knobs above — and neither are RFC-014's `toolHints`, + * `toolsets` and `surface`, which a server's own `toolHints` config turns + * off. */ enabledCapabilities?: string[]; diff --git a/test/mcpl-toolsets-wiring.test.ts b/test/mcpl-toolsets-wiring.test.ts index 6a34ec1d5..5b4c2ee2f 100644 --- a/test/mcpl-toolsets-wiring.test.ts +++ b/test/mcpl-toolsets-wiring.test.ts @@ -85,8 +85,8 @@ async function hintingServer(answer: Json, { pushEvents = true, listing = (): bo return { server, showPeek }; } -async function connectedTo(server: Awaited>, options: Partial = {}) { - const fw = await makeFramework([{ id: 'arc', toolPrefix: 'arc', url: server.url, toolHints: true }], options); +async function connectedTo(server: Awaited>, options: Partial = {}, config: Partial = {}) { + const fw = await makeFramework([{ id: 'arc', toolPrefix: 'arc', url: server.url, toolHints: true, ...config }], options); await waitFor(() => fw.framework.getAllTools().some((t) => t.name === 'arc--status'), 'static tools listed'); await waitFor(() => server.received.some((m) => m.method === 'toolsets/list'), 'toolsets/list after connecting'); return fw; @@ -324,3 +324,37 @@ test('a tool change with no result to ride on is a notice only where pushEvents await refused.server.close(); } }); + +test('a capability mask that names none of RFC-014\'s declarations leaves them alone: none is logged as masked, and hints still apply', async () => { + const advert: Json = { + ...ADVERT, + toolsets: { build: { title: 'Building', summary: 'Blocks', default: 'closed' } }, + surface: { title: 'Arcade' }, + }; + const server = await startScriptedServer({ + advert, + tools: [STATUS, PEEK], + listRev: () => 0, + on: (msg, peer) => { + if (msg.method === 'toolsets/list') { peer.reply(msg.id, { open: [], _meta: { 'mcpl/hints': { rev: 1, tools: [] } } }); return; } + if (msg.id !== undefined) peer.error(msg.id, -32601, 'Method not found'); + }, + }); + const logged: string[] = []; + const error = console.error; + console.error = (...args: unknown[]) => { logged.push(args.map(String).join(' ')); }; + let fw: Fw | undefined; + try { + fw = await connectedTo(server, {}, { enabledCapabilities: ['pushEvents'] }); + const masked = logged.filter((line) => line.includes('masked by host config')); + assert.ok(!masked.some((line) => /toolHints|toolsets|surface/.test(line)), `nothing of RFC-014 masked: ${masked.join(' | ')}`); + const names = fw.framework.getAllTools().map((t) => t.name); + assert.ok(names.includes('arc--status'), 'the static tool is listed'); + assert.ok(!names.includes('arc--peek'), 'the dynamic tool is not: hints apply'); + assert.ok(names.includes('toolsets--look'), 'the meta-tools are offered'); + } finally { + console.error = error; + await fw?.stop(); + await server.close(); + } +}); From 79171774aec7ab2b2f6f5fb788089fb344499cae Mon Sep 17 00:00:00 2001 From: Octavia-2412 Date: Sun, 25 Oct 2026 19:04:59 -0400 Subject: [PATCH 08/46] fix(mcpl): removing a server releases and forgets its toolset controller A disconnect's permanent removal clears the server's feature-set and checkpoint state itself, since a connection that already closed transiently (its reconnect pending) emits no second close. Its toolset controller, though, was detached only from the close handler, and ToolsetHub.forget had no caller. So a toolHints server removed while its reconnect was pending stayed active and presenting; re-added under the same id with toolHints off, its stale presence still hid its dynamic tools from the tool list, and look still spoke for it. The removal now calls forget beside the other two, and forget has the controller let go of whatever connection it holds (release) before dropping it, so a server added later under the id starts from nothing. A restart goes through the same removal: one under the same id with toolHints on keeps the controller, and with it the ended ids it told, the definitions it showed and the events it saw, as before; one that moves the id or turns toolHints off forgets it. Tests: a toolHints server whose connection is lost (reconnect pending) is disconnected, and a server added under its id with hints off lists its dynamic tool, while look says no server presents toolsets (at 8aaee1ca the tool stayed hidden). A restart under the same id with toolHints doesn't tell an ended record again; that passes at 8aaee1ca, which never forgot, and fails once a restart forgets. A toolHints server whose reconnect is pending, restarted with toolHints off, lists every tool, and one restarted under another id leaves a server added under the old id listing every tool (both fail at 8aaee1ca, and each fails once a restart keeps the controller in its case). Co-Authored-By: Claude Opus 5.5 --- src/framework.ts | 5 +-- src/mcpl/toolset-host.ts | 9 ++++- test/mcpl-tool-hints.test.ts | 17 ++++++++++ test/mcpl-toolsets-wiring.test.ts | 56 +++++++++++++++++++++++++++++++ 4 files changed, 84 insertions(+), 3 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index b26d2776d..819f8b804 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -28595,7 +28595,7 @@ export class AgentFramework { return this.trackOperatorOperation('mcpl:disconnect', () => this.runDisconnectMcplServer(id)); } - private async runDisconnectMcplServer(id: string): Promise { + private async runDisconnectMcplServer(id: string, opts: { keepToolsets?: boolean } = {}): Promise { if (!this.mcplServerRegistry) { throw new Error('MCPL subsystem is not initialized'); } @@ -28648,6 +28648,7 @@ export class AgentFramework { // from close(), and the transient path deliberately preserves checkpoints. this.featureSetManager?.removeServer(id); this.checkpointManager?.removeServer(id); + if (!opts.keepToolsets) this.toolsetHub?.forget(id); // Only this server's own claims, as the prefix map holds them rather // than as its configuration reads now: a server connected since under @@ -28757,7 +28758,7 @@ export class AgentFramework { this.restartClaims().ids.set(config.id, claim); this.restartClaims().prefixes.set(prefix, claim); try { - await this.runDisconnectMcplServer(id); + await this.runDisconnectMcplServer(id, { keepToolsets: config.id === id && config.toolHints === true }); try { await this.runConnectMcplServer(config, claim); } catch (error) { diff --git a/src/mcpl/toolset-host.ts b/src/mcpl/toolset-host.ts index 167af18a8..74272b710 100644 --- a/src/mcpl/toolset-host.ts +++ b/src/mcpl/toolset-host.ts @@ -120,6 +120,10 @@ export class ToolsetController { connection.setReadTap((read, epoch) => this.onRead(connection, read, epoch)); } + release(): void { + if (this.connection) this.detach(this.connection); + } + /** The server's connection is gone for good (disconnected, removed). */ detach(connection: McplServerConnection): void { if (this.connection !== connection) return; @@ -653,8 +657,11 @@ export class ToolsetHub { return this.controllers.get(serverId); } - /** A server's configuration went (removed): forget its controller. */ + /** A server's configuration went (removed): its controller lets go of the + * connection it holds, and is forgotten, so a server added later under + * the same id starts from nothing. */ forget(serverId: string): void { + this.controllers.get(serverId)?.release(); this.controllers.delete(serverId); } diff --git a/test/mcpl-tool-hints.test.ts b/test/mcpl-tool-hints.test.ts index fe03cbccc..739a112f1 100644 --- a/test/mcpl-tool-hints.test.ts +++ b/test/mcpl-tool-hints.test.ts @@ -1577,3 +1577,20 @@ test('an open\'s tool change goes with its result where the agent reads it, and await waitFor(() => notices(host.fw).some((x) => x.includes('[arc/build] B') && x.includes('place(x?: int) — Place a block.')), 'the agent told as a notice'); } finally { await host.done(); } }); + +test('a restart under the same id with toolHints keeps what the host told: an end told before is not told again', async () => { + const t = await setup((msg, _peer, state) => { + if (msg.method === 'toolsets/list') state.ended = [{ id: 'e1', toolset: 'game', instance: 'g1', reason: 'the game is over' }]; + return false; + }); + const told = () => notices(t.fw).filter((x) => x.includes('ended: the game is over')).length; + try { + await waitFor(() => told() === 1, 'the end told'); + const lists = t.methods().filter((m) => m === 'toolsets/list').length; + await t.fw.framework.restartMcplServer('arc'); + await waitFor(() => t.methods().filter((m) => m === 'toolsets/list').length > lists, 'toolsets/list after the restart'); + await roundTrip(t); + await nextMacrotask(); + assert.equal(told(), 1, 'told once, across the restart'); + } finally { await t.done(); } +}); diff --git a/test/mcpl-toolsets-wiring.test.ts b/test/mcpl-toolsets-wiring.test.ts index 5b4c2ee2f..117b4655c 100644 --- a/test/mcpl-toolsets-wiring.test.ts +++ b/test/mcpl-toolsets-wiring.test.ts @@ -358,3 +358,59 @@ test('a capability mask that names none of RFC-014\'s declarations leaves them a await server.close(); } }); + +test('a toolHints server removed while its reconnect is pending leaves no toolset state: a server added again under its id with hints off lists every tool', async () => { + const first = await hintingServer({ content: [] }); + const fw = await connectedTo(first.server, {}, { reconnect: true, reconnectIntervalMs: 50 }); + const second = await startScriptedServer({ advert: { featureSets: ADVERT.featureSets }, tools: [STATUS, PEEK] }); + try { + assert.ok(!fw.framework.getAllTools().some((t) => t.name === 'arc--peek'), 'a dynamic tool, while hints apply'); + await first.server.close(); + await waitFor(() => fw.framework.listMcplServers().some((s) => s.id === 'arc' && !s.connected), 'the connection lost, its reconnect pending'); + await fw.framework.disconnectMcplServer('arc'); + await fw.framework.connectMcplServer({ id: 'arc', toolPrefix: 'arc', url: second.url }); + await waitFor(() => fw.framework.getAllTools().some((t) => t.name === 'arc--status'), 'the new server\'s tools listed'); + assert.ok(fw.framework.getAllTools().some((t) => t.name === 'arc--peek'), 'peek is listed: no toolset state of the removed server hides it'); + assert.match(resultText(await callTool(fw.framework, 'toolsets--look', {})), /No MCPL server presents toolsets/); + } finally { + await fw.stop(); + await second.close(); + } +}); + +test('a toolHints server whose reconnect is pending, restarted with toolHints off, leaves no toolset state: every tool is listed', async () => { + const first = await hintingServer({ content: [] }); + const fw = await connectedTo(first.server, {}, { reconnect: true, reconnectIntervalMs: 50 }); + const second = await startScriptedServer({ advert: { featureSets: ADVERT.featureSets }, tools: [STATUS, PEEK] }); + try { + assert.ok(!fw.framework.getAllTools().some((t) => t.name === 'arc--peek'), 'a dynamic tool, while hints apply'); + await first.server.close(); + await waitFor(() => fw.framework.listMcplServers().some((s) => s.id === 'arc' && !s.connected), 'the connection lost, its reconnect pending'); + await fw.framework.restartMcplServer('arc', { id: 'arc', toolPrefix: 'arc', url: second.url, toolHints: false }); + await waitFor(() => fw.framework.getAllTools().some((t) => t.name === 'arc--status'), 'the restarted server\'s tools listed'); + assert.ok(fw.framework.getAllTools().some((t) => t.name === 'arc--peek'), 'peek is listed: no toolset state from before the restart hides it'); + assert.match(resultText(await callTool(fw.framework, 'toolsets--look', {})), /No MCPL server presents toolsets/); + } finally { + await fw.stop(); + await second.close(); + } +}); + +test('a toolHints server whose reconnect is pending, restarted under another id, leaves no toolset state under the old one', async () => { + const first = await hintingServer({ content: [] }); + const fw = await connectedTo(first.server, {}, { reconnect: true, reconnectIntervalMs: 50 }); + const second = await startScriptedServer({ advert: { featureSets: ADVERT.featureSets }, tools: [STATUS, PEEK] }); + const third = await startScriptedServer({ advert: { featureSets: ADVERT.featureSets }, tools: [STATUS, PEEK] }); + try { + await first.server.close(); + await waitFor(() => fw.framework.listMcplServers().some((s) => s.id === 'arc' && !s.connected), 'the connection lost, its reconnect pending'); + await fw.framework.restartMcplServer('arc', { id: 'moved', toolPrefix: 'moved', url: second.url, toolHints: true }); + await fw.framework.connectMcplServer({ id: 'arc', toolPrefix: 'arc', url: third.url }); + await waitFor(() => fw.framework.getAllTools().some((t) => t.name === 'arc--status'), 'the server added under the old id listed'); + assert.ok(fw.framework.getAllTools().some((t) => t.name === 'arc--peek'), 'peek is listed: no toolset state of the moved server hides it'); + } finally { + await fw.stop(); + await second.close(); + await third.close(); + } +}); From 609b8dac0a6f69c5da14d2e12fe79dee1ff26f7d Mon Sep 17 00:00:00 2001 From: Octavia-2412 Date: Sun, 25 Oct 2026 19:06:48 -0400 Subject: [PATCH 09/46] fix(mcpl): the toolset host logs a server's values redacted, as log fields The controller's log lines printed values a server chose raw: a push's eventId (a duplicate, or one with coalesce), a hint's current and previous rev and a tools/list's rev, a proposed and a returned instance name, and the text of a failed tools/list or toolsets/list. This codebase's rule for such values is that they're redacted with the server's configuration (its url, token and env) and printed through log-value, so one value stays one field of one line and can't pose as another record or field. Each now goes through logValueRedacted with redactServerText for the server's configuration (field), and a failure's text through redactServerText then logDetail (failure). Test: a push with hints whose eventId holds the configured token and a line break, sent twice, logs the duplicate with neither, as one quoted field; at 8aaee1ca the line printed the token and broke onto a second line. The other lines use the same two helpers and are checked by reading. Co-Authored-By: Claude Opus 5.5 --- src/mcpl/toolset-host.ts | 28 +++++++++++++++++++--------- test/mcpl-toolsets-wiring.test.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 9 deletions(-) diff --git a/src/mcpl/toolset-host.ts b/src/mcpl/toolset-host.ts index 74272b710..2bcdbab99 100644 --- a/src/mcpl/toolset-host.ts +++ b/src/mcpl/toolset-host.ts @@ -26,6 +26,8 @@ import { McplRequestError, type McplServerConnection, type HintRead } from './server-connection.js'; import type { McplServerConfig } from './types.js'; import { isToolAllowed } from './tool-policy.js'; +import { redactServerText } from './protocol-family.js'; +import { logDetail, logValueRedacted } from './log-value.js'; import { ToolPresence, emptyChange, isEmptyChange, keyOf, type ChangeSet, type MetaToolNames, @@ -104,6 +106,14 @@ export class ToolsetController { this.deps.log(`${this.serverId}: ${line}`); } + private field(value: unknown): string { + return logValueRedacted(value, (text) => redactServerText(text, this.config)); + } + + private failure(error: unknown): string { + return logDetail(redactServerText(error instanceof Error ? error.message : String(error), this.config)); + } + /** A restart can bring a new configuration under the same id. */ setConfig(config: McplServerConfig): void { this.config = config; @@ -183,7 +193,7 @@ export class ToolsetController { // RFC §7: a push carrying hints is never coalesced; when its changes // would apply is undefined, so they're ignored and the state re-read. if (p.coalesce !== undefined && p.coalesce !== null) { - this.log(`push ${String(p.eventId ?? '')} carries hints and coalesce (RFC-014 §7 forbids it): its hints are ignored; recovering`); + this.log(`push ${this.field(p.eventId ?? '')} carries hints and coalesce (RFC-014 §7 forbids it): its hints are ignored; recovering`); this.scheduleRecovery(); return; } @@ -191,7 +201,7 @@ export class ToolsetController { const eventId = typeof p.eventId === 'string' || typeof p.eventId === 'number' ? String(p.eventId) : ''; if (eventId) { if (this.seenEvents.has(eventId)) { - this.log(`push ${eventId} was read before: its hints are ignored`); + this.log(`push ${this.field(eventId)} was read before: its hints are ignored`); return; } this.seenEvents.add(eventId); @@ -261,9 +271,9 @@ export class ToolsetController { private applyHints(h: unknown, source: string): ChangeSet { if (h === undefined || !this.presence.active) return emptyChange(); const before = this.presence.view(); - const last = this.presence.rev; + const last = this.field(this.presence.rev); const r = this.presence.apply(h); - const rev = String((h as { rev?: unknown } | null)?.rev); + const rev = this.field((h as { rev?: unknown } | null)?.rev); if (r.behind) { this.log(`hint change rev ${rev} (${source}) is not after rev ${last}: ignored; recovering from tools/list and toolsets/list`); this.scheduleRecovery(); @@ -287,7 +297,7 @@ export class ToolsetController { const before = this.presence.view(); this.presence.setTools(tools); if (this.presence.active && this.presence.noteListRev(isObj(last._meta) ? last._meta['mcpl/rev'] : undefined)) { - this.log(`tools/list was written after rev ${String(isObj(last._meta) ? last._meta['mcpl/rev'] : '')}, past the last rev read: a message was lost; recovering`); + this.log(`tools/list was written after rev ${this.field(isObj(last._meta) ? last._meta['mcpl/rev'] : '')}, past the last rev read: a message was lost; recovering`); this.scheduleRecovery(); } return this.presence.diff(before, this.presence.view()); @@ -496,7 +506,7 @@ export class ToolsetController { if (this.presence.movesOwed) this.deps.refreshTools(this.serverId); return true; } catch (error) { - this.log(`tools/list failed: ${error instanceof Error ? error.message : String(error)}`); + this.log(`tools/list failed: ${this.failure(error)}`); return false; } } @@ -506,7 +516,7 @@ export class ToolsetController { const r = await connection.sendToolsetsList(timeoutMs); return this.claim(r); } catch (error) { - this.log(`toolsets/list (${why}) failed: ${error instanceof Error ? error.message : String(error)}`); + this.log(`toolsets/list (${why}) failed: ${this.failure(error)}`); return emptyChange(); } } @@ -547,7 +557,7 @@ export class ToolsetController { if (!isObj(r)) return null; return opts.shown ? this.claim(r) : this.changeOf(r); } catch (error) { - this.log(`toolsets/list (look) failed: ${error instanceof Error ? error.message : String(error)}`); + this.log(`toolsets/list (look) failed: ${this.failure(error)}`); return null; } } @@ -565,7 +575,7 @@ export class ToolsetController { const res = isObj(r) ? r : {}; const inst = typeof res.instance === 'string' && res.instance ? res.instance : instance; // RFC §5.2: a proposed name is used as given or the open refused. - if (instance && inst !== instance) this.log(`proposed instance "${instance}" came back as "${inst}" (RFC-014 §5.2 forbids renaming): using the server's`); + if (instance && inst !== instance) this.log(`proposed instance ${this.field(instance)} came back as ${this.field(inst)} (RFC-014 §5.2 forbids renaming): using the server's`); const key = keyOf(toolset, this.presence.declared[toolset]?.instances ? inst : undefined); const title = typeof res.title === 'string' && res.title ? res.title : this.presence.openRecords().find((o) => keyOf(o.toolset, o.instance) === key)?.title; return { diff --git a/test/mcpl-toolsets-wiring.test.ts b/test/mcpl-toolsets-wiring.test.ts index 117b4655c..a018c70a0 100644 --- a/test/mcpl-toolsets-wiring.test.ts +++ b/test/mcpl-toolsets-wiring.test.ts @@ -414,3 +414,29 @@ test('a toolHints server whose reconnect is pending, restarted under another id, await third.close(); } }); + +test('a server-chosen value the toolset host logs is redacted with the server\'s configuration and stays one field of one line', async () => { + const token = 'sekret-token-4417'; + const { server, showPeek } = await hintingServer({ content: [] }); + const fw = await connectedTo(server, {}, { token }); + const logged: string[] = []; + const error = console.error; + console.error = (...args: unknown[]) => { logged.push(args.map(String).join(' ')); }; + try { + const eventId = `evt ${token}\nnext line`; + const push = (hints: Json) => server.peer.request('push/event', { + featureSet: 'arc.events', eventId, timestamp: new Date().toISOString(), payload: { content: [{ type: 'text', text: 'moved' }] }, hints, + }); + await push(showPeek()); + await push(showPeek()); + await waitFor(() => logged.some((line) => line.includes('was read before')), 'the duplicate logged'); + const line = logged.find((l) => l.includes('was read before'))!; + assert.ok(!line.includes(token), `no credential: ${line}`); + assert.ok(!line.includes('\n'), `one line: ${JSON.stringify(line)}`); + assert.match(line, /push "evt[^"]*next[^"]*line" was read before/, `one quoted field: ${line}`); + } finally { + console.error = error; + await fw.stop(); + await server.close(); + } +}); From 7b4c3473bf6fbd8916eaa8a6c0a15483258bd75f Mon Sep 17 00:00:00 2001 From: Octavia-2412 Date: Sun, 25 Oct 2026 19:09:40 -0400 Subject: [PATCH 10/46] fix(mcpl): the meta-tools' refusals say what there is, and close says what stops - A refusal for a name the server doesn't have now says what it has: look's, open's and close's "has no toolset" list its toolsets (or say it has none), look's "no open instance" lists the open instances, look's "has no tool" names look as act's refusal already did, and "no server that presents toolsets has ..." names look. - With no server presenting toolsets, a meta-tool call no longer asks for a server name that can't be given ("name a server: (no MCPL server presents toolsets now)"); it says no server presents toolsets now and that MCPL tools are called directly, by name. A named server that sends no hints, look with nothing presented, and the note telling the agent a server withdrew toolHints say the same; each had said every tool is in the agent's tool list, which an agent's allowedTools or the tool presentation can make untrue. - A toolsets--act call whose outcome is unknown (anything but a request known not to have been sent) keeps the connection's text, whose remedy names no tool the agent has, and adds that toolsets--look {server} asks it what's open (withLookHint), on the model's dispatch and the programmatic call alike. - close's description said its tools and its events stop; closing an undeclared group only gates its calls, and the server, never told, keeps its events. It now says the server is told and stops its events for a toolset it declares. Tests: the refusals (fail at 8aaee1ca: "arc has no toolset "nope"" and the rest), and a meta-tool naming a server that sends no hints (fails with that refusal's earlier wording, "its tools are in your tool list"); and an act whose connection drops before the answer names look, through a model's turn and called in process (at 8aaee1ca the connection's text alone). close's description is text, checked by reading. Co-Authored-By: Claude Opus 5.5 --- src/framework.ts | 11 +++++-- src/mcpl/toolset-host.ts | 2 +- src/mcpl/toolsets-module.ts | 36 +++++++++++++-------- test/mcpl-tool-hints.test.ts | 63 ++++++++++++++++++++++++++++++++++++ 4 files changed, 95 insertions(+), 17 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index 819f8b804..71a207869 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -764,7 +764,7 @@ import type { SessionUsageSnapshot, UsageUpdatedEvent } from './usage/types.js'; import { McplProtocolVersionError, McplRequestError, McplUnreapedLaunchError, stderrTailOf, toolsetDeclarationsOf, withStderr, type McplConnectionCloseInfo, type McplServerConnection } from './mcpl/server-connection.js'; import { ToolsetHub, TOOLSETS_MODULE, META_TOOL_NAMES } from './mcpl/toolset-host.js'; import { isEmptyChange } from './mcpl/tool-presence.js'; -import { ToolsetsModule, resolveAct, type TakenChange } from './mcpl/toolsets-module.js'; +import { ToolsetsModule, resolveAct, withLookHint, type TakenChange } from './mcpl/toolsets-module.js'; import { ModernMcpConnection } from './mcpl/modern-connection.js'; import { WITHHELD_TEXT, asText, credentialRedactor, redactUrl, redactServerText, resolveServerBinding, serverConfigProblems, serverCredentialPatterns, serverTextRedactor, type CredentialPattern } from './mcpl/protocol-family.js'; import { MALFORMED_CONTENT_NOTICE, normalizeStandardToolResult } from './mcpl/tool-result-normalize.js'; @@ -22460,7 +22460,7 @@ export class AgentFramework { if (taken) taken.toolsets.tell(taken.cs); return { success: false, - error: redactServerText(err instanceof Error ? err.message : err, config ?? {}), + error: this.withActStateHint(call, serverId, err, redactServerText(err instanceof Error ? err.message : err, config ?? {})), isError: true, }; } @@ -22472,6 +22472,11 @@ export class AgentFramework { return text.endsWith(change.toWellFormed()) && (maxChars === undefined || text.length <= maxChars) ? joined : null; } + private withActStateHint(call: ToolCall, serverId: string, error: unknown, text: string): string { + if (!this.toolsetActCalls?.has(call) || (error instanceof McplRequestError && error.outcome === 'not-sent')) return text; + return withLookHint(serverId, text); + } + // --------------------------------------------------------------------------- // Client-side programmatic tool calling (code_execution) // --------------------------------------------------------------------------- @@ -30181,7 +30186,7 @@ export class AgentFramework { toolName: call.name, agentName, moduleName: `mcpl:${serverId}`, - result: { success: false, error: err.message, isError: true }, + result: { success: false, error: this.withActStateHint(call, serverId, raw, err.message), isError: true }, }); }; diff --git a/src/mcpl/toolset-host.ts b/src/mcpl/toolset-host.ts index 2bcdbab99..644fc66fe 100644 --- a/src/mcpl/toolset-host.ts +++ b/src/mcpl/toolset-host.ts @@ -616,7 +616,7 @@ export class ToolsetController { this.presence.deactivate(); this.log('the server withdrew toolHints: every tool is static from now on'); const cs = this.presence.diff(before, this.presence.view()); - cs.notes.push(`${this.serverId} no longer presents toolsets: its tools are all in your tool list now.`); + cs.notes.push(`${this.serverId} no longer presents toolsets: its tools are called directly now, by name, not through ${META_TOOL_NAMES.act}.`); this.deliver(cs); return; } diff --git a/src/mcpl/toolsets-module.ts b/src/mcpl/toolsets-module.ts index 419813f32..099809a9f 100644 --- a/src/mcpl/toolsets-module.ts +++ b/src/mcpl/toolsets-module.ts @@ -85,7 +85,7 @@ const META_TOOLS: ToolDefinition[] = [ }, { name: 'close', - description: 'Close an open toolset or instance: its tools and its events stop. Closing a game in progress may count as leaving it.', + description: 'Close an open toolset or instance: its tools stop, and for a toolset the server declares, the server is told and stops its events too. Closing a game in progress may count as leaving it.', inputSchema: { type: 'object', properties: { @@ -170,11 +170,11 @@ function pick(hub: ToolsetHub, server: unknown): ToolsetController { const ids = hub.presenting().map((c) => c.serverId); if (!id) { if (ids.length === 1) return hub.get(ids[0]!)!; - return fail(`name a server: ${ids.join(', ') || '(no MCPL server presents toolsets now)'}`); + return fail(ids.length ? `name a server: ${ids.join(', ')}` : NONE_PRESENTS); } const c = hub.get(id); if (!c) return fail(`no server "${id}" presents toolsets; the ones that do: ${ids.join(', ') || 'none'}`); - if (!c.presence.active) return fail(`${id} sends no tool hints now: its tools are in your tool list`); + if (!c.presence.active) return fail(`${id} sends no tool hints now: its tools are called directly, by name, not through ${META_TOOL_NAMES.act}`); return c; } @@ -186,9 +186,15 @@ function owner(hub: ToolsetHub, server: unknown, has: (c: ToolsetController) => if (str(server)) return pick(hub, server); const owners = hub.presenting().filter(has); if (owners.length === 1) return owners[0]!; - return fail(owners.length - ? `${what} is on several servers (${owners.map((c) => c.serverId).join(', ')}): add server` - : `no server that presents toolsets has ${what}`); + if (owners.length) return fail(`${what} is on several servers (${owners.map((c) => c.serverId).join(', ')}): add server`); + return fail(hub.presenting().length ? `no server that presents toolsets has ${what}: ${META_TOOL_NAMES.look} shows what they have` : NONE_PRESENTS); +} + +const NONE_PRESENTS = 'no MCPL server presents toolsets now: MCPL tools are called directly, by name'; + +function noToolset(c: ToolsetController, toolset: string): string { + const names = [...Object.keys(c.presence.declared), ...c.presence.undeclaredGroups]; + return `${c.serverId} has no toolset "${toolset}"; ${names.length ? `it has ${names.join(', ')}` : 'it has none'}`; } /** What a `toolsets--act` call resolves to: the server's tool, its instance and arguments. */ @@ -358,7 +364,7 @@ export class ToolsetsModule implements Module { else c.tell(cs); return textResult(lines.join('\n')); } - return fail(`${c.serverId} has no toolset "${toolset}"${openable.length ? `; it has ${openable.join(', ')}` : ''}`); + return fail(noToolset(c, toolset)); } /** @@ -432,12 +438,16 @@ export class ToolsetsModule implements Module { const p = c.presence; let toolset = str(args.toolset); const instance = str(args.instance); - if (!toolset && instance) toolset = p.openRecords().find((o) => o.instance === instance)?.toolset ?? fail(`no open instance "${instance}" on ${c.serverId}`); - if (!toolset) return textResult(p.catalog()); + if (!toolset && instance) { + const open = p.openRecords().filter((o) => o.instance !== undefined); + toolset = open.find((o) => o.instance === instance)?.toolset + ?? fail(`no open instance "${instance}" on ${c.serverId}; ${open.length ? `open: ${open.map((o) => `${o.toolset}:${o.instance}`).join(', ')}` : 'no instance is open there'}`); + } + if (!toolset) return textResult(p.catalog({ mark })); if (p.isOpen(toolset, instance) || (!instance && p.openRecords(toolset).length === 1)) { const rec = p.isDeclared(toolset) ? (instance ? p.openRecords(toolset).find((o) => o.instance === instance) : p.openRecords(toolset)[0]) : undefined; const key = keyOf(toolset, rec?.instance); - const lines = p.toolBlock(key, this.toolsAt(c, key), { describeAll: true, title: rec?.title }); + const lines = p.toolBlock(key, this.toolsAt(c, key), { describeAll: true, title: rec?.title, seen: mark ? undefined : new Set() }); const close = p.closeHint(key); if (close) lines.push(`${close} closes it.`); return textResult(lines.join('\n')); @@ -457,12 +467,12 @@ export class ToolsetsModule implements Module { const names = p.allTools.filter((t) => p.toolsetOf(t.name) === toolset).map((t) => t.name); return textResult(`${c.serverId}/${toolset}: ${names.join(', ')}; not open: ${p.openHint(toolset)} opens it.`); } - return fail(`${c.serverId} has no toolset "${toolset}"`); + return fail(noToolset(c, toolset!)); } private lookTool(c: ToolsetController, tool: string, instance: string | undefined): ToolResult { const p = c.presence; - if (!p.tool(tool)) fail(`${c.serverId} has no tool "${tool}"`); + if (!p.tool(tool)) fail(`${c.serverId} has no tool "${tool}": ${META_TOOL_NAMES.look} {server:"${c.serverId}"} shows what's available`); const ts = p.toolsetOf(tool); const where = [...p.view().entries()].filter(([, e]) => e.tools.has(tool)).map(([k]) => k); const key = instance && ts ? keyOf(ts, instance) : where[0]; @@ -521,6 +531,6 @@ export class ToolsetsModule implements Module { else c.tell(cs); return textResult(text); } - return fail(`${c.serverId} has no toolset "${toolset}"`); + return fail(noToolset(c, toolset!)); } } diff --git a/test/mcpl-tool-hints.test.ts b/test/mcpl-tool-hints.test.ts index 739a112f1..ff131eaab 100644 --- a/test/mcpl-tool-hints.test.ts +++ b/test/mcpl-tool-hints.test.ts @@ -18,6 +18,7 @@ import { startScriptedServer, makeFramework, waitFor, callTool, resultText, windowText, type Json, type ScriptedPeer, } from './helpers/toolset-servers.js'; +import { createMockResponse } from './helpers/mock-membrane.js'; const TOOLS: Json[] = [ { name: 'status', description: 'Status of the arcade.', inputSchema: { type: 'object', properties: {} } }, @@ -644,6 +645,68 @@ test('an open or close whose outcome the host can\'t know is reported as unconfi } finally { await refused.done(); } }); +test('a refusal for a name the server doesn\'t have says what it has, or names look', async () => { + const t = await setup(); + try { + const refusals: Array<[string, Json, RegExp]> = [ + ['toolsets--look', { server: 'arc', toolset: 'nope' }, /^arc has no toolset "nope"; it has game, build, cave$/], + ['toolsets--close', { server: 'arc', toolset: 'nope' }, /^arc has no toolset "nope"; it has game, build, cave$/], + ['toolsets--open', { server: 'arc', toolset: 'nope' }, /^arc has no toolset "nope"; it has game, build, cave$/], + ['toolsets--look', { server: 'arc', tool: 'nope' }, /^arc has no tool "nope": toolsets--look \{server:"arc"\} shows what's available$/], + ['toolsets--look', { server: 'arc', instance: 'g9' }, /^no open instance "g9" on arc; no instance is open there$/], + ['toolsets--open', { toolset: 'nope' }, /^no server that presents toolsets has a toolset "nope": toolsets--look shows what they have$/], + ]; + for (const [name, input, refusal] of refusals) { + const r = await callTool(t.fw.framework, name, input); + assert.ok(r.isError, `${name} ${JSON.stringify(input)} is refused`); + assert.match(resultText(r), refusal); + } + } finally { await t.done(); } + + const none = await setup(() => false, {}, { ...ADVERT, toolHints: false }); + try { + for (const input of [{}, { toolset: 'game' }]) { + const r = await callTool(none.fw.framework, 'toolsets--open', input); + assert.ok(r.isError); + assert.match(resultText(r), /^no MCPL server presents toolsets now: MCPL tools are called directly, by name$/); + } + const named = await callTool(none.fw.framework, 'toolsets--open', { server: 'arc', toolset: 'game' }); + assert.ok(named.isError); + assert.equal(resultText(named), 'arc sends no tool hints now: its tools are called directly, by name, not through toolsets--act'); + } finally { await none.done(); } +}); + +test('a toolsets--act call whose outcome the host can\'t know names look, for the model and for a caller in process', async () => { + const dropsOnPeek: Handler = (msg, peer) => { + if (msg.method !== 'tools/call' || (msg.params as Json).name !== 'peek') return false; + peer.ws.terminate(); + return true; + }; + const t = await setup(dropsOnPeek); + try { + t.server.peer.notify('tools/hints', t.state.hint([{ name: 'peek', visible: true }])); + await roundTrip(t); + t.fw.membrane.pushResponse(createMockResponse([{ type: 'tool_use', id: 'toolu_act', name: 'toolsets--act', input: { server: 'arc', tool: 'peek', args: {} } }] as never, 'tool_use')); + t.fw.membrane.pushResponse(createMockResponse([{ type: 'text', text: 'turn over' }])); + t.fw.framework.pushEvent({ type: 'external-message', source: 'test', content: [{ type: 'text', text: 'go' }], metadata: {}, triggerInference: true } as never); + await waitFor(() => windowText(t.fw.framework).some((x) => x.includes('turn over')), 'the turn ended'); + const results = (t.fw.membrane.lastStream?.receivedToolResults ?? []).flat() as Array<{ toolUseId: string; content: unknown }>; + const text = JSON.stringify(results.find((r) => r.toolUseId === 'toolu_act')?.content ?? null); + assert.match(text, /closed before it answered/); + assert.match(text, /toolsets--look \{server:\\"arc\\"\} asks it what's open, or says it couldn't\./); + } finally { await t.done(); } + + const inProcess = await setup(dropsOnPeek); + try { + inProcess.server.peer.notify('tools/hints', inProcess.state.hint([{ name: 'peek', visible: true }])); + await roundTrip(inProcess); + const r = await callTool(inProcess.fw.framework, 'toolsets--act', { server: 'arc', tool: 'peek', args: {} }); + assert.ok(r.isError); + assert.match(resultText(r), /closed before it answered/); + assert.match(resultText(r), /toolsets--look \{server:"arc"\} asks it what's open, or says it couldn't\.$/); + } finally { await inProcess.done(); } +}); + test('the host\'s argument check is bounded: no server pattern runs, and deep or wide schemas are left to the server', async () => { const { checkArgs } = await import('../src/mcpl/tool-signature.js'); // A pattern that backtracks catastrophically is never evaluated. From 5c8017a7865d9440b24d76a85a4d6dda296291ae Mon Sep 17 00:00:00 2001 From: Octavia-2412 Date: Sun, 25 Oct 2026 19:13:44 -0400 Subject: [PATCH 11/46] fix(mcpl): dynamic tools' names count for pattern checks, refused acts are traced, and two texts are corrected Since 4d13f1e6 a dynamic tool keeps its class and lifecycle selectors name it (act goes down the direct-call path), and the resolver's map holds its listed name. Three places hadn't followed: - checkToolPatterns judged a pattern against the names the model is offered only, so a toolClassOverrides key or lifecycle selector naming a dynamic tool was reported "matches no tool" though it applies. It now counts every listed name (patternableToolNames). - executeToolCallFrom's act branch returned an act that couldn't be resolved, or that the caller's allowedTools refused, without the tool:failed trace the permission check beside it emits for the same caller; both now go through one traced-failure helper. Either refusal can quote the server (a schema's enum values, the server's name for its tool), so each trace is redacted with every server's configuration, as is the model's act refusal's trace beside it. - The duplicate-listing alert said the model is offered the first listing, which a dynamic tool never is: the first listing is the one used, offered or called through toolsets--act. resolveMcplTool's doc and executeToolCallFrom's comment said the listing "offered" the name; it lists it, offered or dynamic. Tests: a class rule naming a dynamic tool isn't reported unmatched while one naming no tool is (8aaee1ca reported "arc--peek" matches no tool); a programmatic act naming no tool gives one tool:failed trace (none at 8aaee1ca); a refused act's trace, in process and from the model, carries a server's credential redacted (fails with either redaction removed). The two texts are checked by reading. The duplicate-listing alert's own tests (mcpl-prefix-rule) read its new wording, and mcpl-config-diagnostics' stub of the renamed method follows the rename. The allowedTools refusal's trace, redacted, has no test in this commit: it belongs with the test of that refusal itself. Co-Authored-By: Claude Opus 5.5 --- src/framework.ts | 41 ++++++++++-------- test/mcpl-config-diagnostics.test.ts | 2 +- test/mcpl-prefix-rule.test.ts | 4 +- test/mcpl-toolsets-wiring.test.ts | 65 ++++++++++++++++++++++++++++ 4 files changed, 91 insertions(+), 21 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index 71a207869..08676898b 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -22239,14 +22239,15 @@ export class AgentFramework { } private async executeToolCallFrom(call: ToolCall, origin: ChannelToolOrigin): Promise { + // A programmatic call, off the dispatcher: no RFC-007 call opened, so a + // denial here is a failure. It's this call's end, so it's traced here. + const failed = (failure: ToolResult, traced = failure.error ?? ''): ToolResult => { + this.emitTrace({ type: 'tool:failed', module: 'framework', tool: call.name, callId: call.id, error: traced }); + return failure; + }; if (origin.kind === 'agent') { - // A programmatic call, off the dispatcher: no RFC-007 call opened, so a - // denial here is a failure. It's this call's end, so it's traced here. const failure = this.agentToolPermissionFailure(origin.agentName, call, false); - if (failure) { - this.emitTrace({ type: 'tool:failed', module: 'framework', tool: call.name, callId: call.id, error: failure.error ?? '' }); - return failure; - } + if (failure) return failed(failure); } if (isPresentationTool(call.name)) return this.editToolPresentation(call.callerAgentName ?? '__ephemeral__', call); @@ -22262,17 +22263,17 @@ export class AgentFramework { // where a programmatic call of that tool goes, with its instance. if (call.name === META_TOOL_NAMES.act && this.toolsetHub && this.mcplServerRegistry) { const target = this.resolveToolsetAct(call); - if (!target.ok) return { success: false, error: target.error, isError: true }; + if (!target.ok) return failed({ success: false, error: target.error, isError: true }, this.everyServerRedactor()(target.error)); if (origin.kind === 'agent') { const failure = this.agentToolPermissionFailure(origin.agentName, target.call, false); - if (failure) return failure; + if (failure) return failed(failure, this.everyServerRedactor()(failure.error ?? '')); } return this.executeMcplToolCall(target.call, target.serverId, target.prefix); } // MCPL tools go where the model's own call of the name goes, by the same // resolver and with the prefix it matched: to the server whose listing - // offers the name, else the longest matching prefix. + // has the name, else the longest matching prefix. if (this.mcplServerRegistry) { const mcpl = this.resolveMcplTool(call.name); if (mcpl) return this.executeMcplToolCall(call, mcpl[0], mcpl[1]); @@ -22366,7 +22367,7 @@ export class AgentFramework { private dispatchToolsetAct(agentName: string, call: ToolCall): void { const target = this.resolveToolsetAct(call); if (!target.ok) { - this.emitTrace({ type: 'tool:failed', module: TOOLSETS_MODULE, tool: call.name, callId: call.id, error: target.error }); + this.emitTrace({ type: 'tool:failed', module: TOOLSETS_MODULE, tool: call.name, callId: call.id, error: this.everyServerRedactor()(target.error) }); this.pushEvent({ type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: TOOLSETS_MODULE, result: this.refusedResult(agentName, call.id, target.error), @@ -23934,8 +23935,9 @@ export class AgentFramework { /** * Find the MCPL server for a tool: [serverId, prefix], or null. - * The tool listing decides: the server whose tools/list offered the - * name, after its tool policy, as of the last tool refresh (a server that + * The tool listing decides: the server whose tools/list listed the name + * (offered to the model, or an RFC-014 dynamic tool, which dispatch then + * refuses by that name), after its tool policy, as of the last tool refresh (a server that * closed since keeps its names until the next one). A name the listing * doesn't hold (invented by the model, or no longer listed) goes to the * longest matching prefix. Prefixes that meet, such as `foo` and @@ -29923,7 +29925,7 @@ export class AgentFramework { 'mcpl-tool-duplicate', serverId, `MCPL server "${serverId}" lists ${one ? 'the tool' : 'the tools'} ${listed} more than once. ` + - `The model is offered the first listing of ${one ? 'it' : 'each'}, and the rest are left out. ` + + `The first listing of ${one ? 'it' : 'each'} is the one used (offered to the model, or called through toolsets--act for a dynamic tool), and the rest are left out. ` + 'A server should list each tool once, so this is for its maintainer to change.', { data: { tools: repeatedTools } }, ); @@ -29932,7 +29934,9 @@ export class AgentFramework { } /** - * Report operator tool-name patterns that match no model-facing tool: + * Report operator tool-name patterns that match no tool name: one the + * model is offered, or an RFC-014 dynamic tool's listed name, which its + * class rules and lifecycle selectors match as a direct call's would: * `toolClassOverrides` keys and each server's `toolLifecycle.observe.tools` * / `toolLifecycle.inputs.tools`. These match `--`, and * the default prefix is `mcpl--`, so a pattern written against @@ -29981,7 +29985,7 @@ export class AgentFramework { && (this.mcplListedServers?.has(id) ?? false), }; }); - const names = this.modelFacingToolNames(); + const names = this.patternableToolNames(); for (const entry of pending) { const verdict = checkToolPattern(entry.pattern, names, servers); @@ -30005,9 +30009,10 @@ export class AgentFramework { /** Every tool name the framework offers to anyone: the shared board plus * each agent's own surface, before presentation visibility (a hidden tool - * is still callable, so classes and lifecycle still apply to it). */ - private modelFacingToolNames(): Set { - const names = new Set(this.getAllTools().map((t) => t.name)); + * is still callable, so classes and lifecycle still apply to it), and + * every name an MCPL server lists, a dynamic tool's included. */ + private patternableToolNames(): Set { + const names = new Set([...this.getAllTools().map((t) => t.name), ...(this.mcplToolServers?.keys() ?? [])]); for (const agent of this.agents.values()) { for (const tool of this.availableToolsForPresentation(agent.name)) names.add(tool.name); } diff --git a/test/mcpl-config-diagnostics.test.ts b/test/mcpl-config-diagnostics.test.ts index 8a5d24de4..7cacaaefa 100644 --- a/test/mcpl-config-diagnostics.test.ts +++ b/test/mcpl-config-diagnostics.test.ts @@ -130,7 +130,7 @@ test('refreshMcplTools: a live server that advertised no MCP tools does not hold sendToolsList: async () => { throw new Error('Method not found'); }, }; fw.mcplServerRegistry = { getAllServers: () => [prov, quiet] }; - fw.modelFacingToolNames = () => new Set(fw.mcplTools.map((t: { name: string }) => t.name)); + fw.patternableToolNames = () => new Set(fw.mcplTools.map((t: { name: string }) => t.name)); fw.toolPatternChecksArmed = true; const capture = captureConsoleError(); try { diff --git a/test/mcpl-prefix-rule.test.ts b/test/mcpl-prefix-rule.test.ts index fa9344e20..62afb4389 100644 --- a/test/mcpl-prefix-rule.test.ts +++ b/test/mcpl-prefix-rule.test.ts @@ -311,7 +311,7 @@ test('each refresh that finds a repeated name raises one alert naming it, and a assert.equal(alert!.agentName, 'dup', 'raised for the server'); assert.equal( alert!.message, - 'MCPL server "dup" lists the tool echo more than once. The model is offered the first listing of it, and the rest are left out. ' + + 'MCPL server "dup" lists the tool echo more than once. The first listing of it is the one used (offered to the model, or called through toolsets--act for a dynamic tool), and the rest are left out. ' + 'A server should list each tool once, so this is for its maintainer to change.', ); assert.deepEqual(alert!.data?.tools, ['echo'], 'named once, though listed three times'); @@ -336,7 +336,7 @@ test('two names repeated in one listing give one alert naming both', async () => await h.refresh(); assert.equal(h.alerts.length, 1); const [alert] = h.alerts; - assert.ok(alert!.message.includes('lists the tools echo, ping more than once. The model is offered the first listing of each,'), alert!.message); + assert.ok(alert!.message.includes('lists the tools echo, ping more than once. The first listing of each is the one used'), alert!.message); assert.deepEqual(alert!.data?.tools, ['echo', 'ping']); assert.deepEqual(offered(h.fw), ['dup--echo', 'dup--ping']); } finally { diff --git a/test/mcpl-toolsets-wiring.test.ts b/test/mcpl-toolsets-wiring.test.ts index a018c70a0..333fa7988 100644 --- a/test/mcpl-toolsets-wiring.test.ts +++ b/test/mcpl-toolsets-wiring.test.ts @@ -440,3 +440,68 @@ test('a server-chosen value the toolset host logs is redacted with the server\'s await server.close(); } }); + +test('a class rule naming a dynamic tool counts as matched; one naming no tool is still reported', async () => { + const { server } = await hintingServer({ content: [] }); + const logged: string[] = []; + const error = console.error; + console.error = (...args: unknown[]) => { logged.push(args.map(String).join(' ')); }; + let fw: Fw | undefined; + try { + fw = await connectedTo(server, { toolClassOverrides: { 'arc--peek': ['computer'], 'arc--nosuch': ['computer'] } }); + await waitFor(() => logged.some((line) => line.includes('arc--nosuch')), 'the unmatched pattern reported'); + assert.ok(!logged.some((line) => line.includes('arc--peek')), `the dynamic tool's pattern isn't reported: ${logged.filter((l) => l.includes('peek')).join(' | ')}`); + } finally { + console.error = error; + await fw?.stop(); + await server.close(); + } +}); + +test('a programmatic toolsets--act that is refused is traced as a failed tool call', async () => { + const { server } = await hintingServer({ content: [] }); + const fw = await connectedTo(server); + const traces: Array> = []; + fw.framework.onTrace((event) => traces.push(event as unknown as Record)); + try { + const r = await callTool(fw.framework, 'toolsets--act', { server: 'arc', tool: 'nosuch' }); + assert.ok(r.isError); + const failed = traces.filter((t) => t.type === 'tool:failed' && t.tool === 'toolsets--act'); + assert.equal(failed.length, 1, 'one tool:failed trace for the act'); + assert.match(String(failed[0]!.error), /arc has no tool "nosuch"/); + } finally { + await fw.stop(); + await server.close(); + } +}); + +test('a refused act\'s trace carries the server\'s text redacted, from a caller in process and from the model', async () => { + const token = 'sekret-token-4417'; + const pick: Json = { name: 'pick', description: 'Pick one.', inputSchema: { type: 'object', properties: { x: { enum: [token, 'b'] } }, required: ['x'] }, _meta: { 'mcpl/presence': 'dynamic', 'mcpl/visible': true } }; + const server = await startScriptedServer({ + advert: ADVERT, + tools: [STATUS, pick], + listRev: () => 1, + on: (msg, peer) => { + if (msg.method === 'toolsets/list') { peer.reply(msg.id, { open: [], _meta: { 'mcpl/hints': { rev: 1, tools: [{ name: 'pick', visible: true }] } } }); return; } + if (msg.id !== undefined) peer.error(msg.id, -32601, 'Method not found'); + }, + }); + const fw = await connectedTo(server, {}, { token }); + const traces: Array> = []; + fw.framework.onTrace((event) => traces.push(event as unknown as Record)); + try { + const r = await callTool(fw.framework, 'toolsets--act', { server: 'arc', tool: 'pick', args: { x: 'c' } }); + assert.match(resultText(r), /must be one of/); + await modelCalls(fw, [{ type: 'tool_use', id: 'toolu_pick', name: 'toolsets--act', input: { server: 'arc', tool: 'pick', args: { x: 'c' } } }]); + const failed = traces.filter((t) => t.type === 'tool:failed' && t.tool === 'toolsets--act').map((t) => String(t.error)); + assert.equal(failed.length, 2, 'both refusals traced'); + for (const error of failed) { + assert.match(error, /must be one of/); + assert.ok(!error.includes(token), `no credential: ${error}`); + } + } finally { + await fw.stop(); + await server.close(); + } +}); From b998505f912e8f29b9447605086445c8d1da6c96 Mon Sep 17 00:00:00 2001 From: Octavia-2412 Date: Sun, 25 Oct 2026 19:19:38 -0400 Subject: [PATCH 12/46] test(mcpl): pin what act and the dispatch already do for dynamic tools #799's tests didn't reach these. Each test is written from what an agent or an observer meets: - A model's call of a dynamic tool by its listed name is refused at the dispatch, naming the act form, and a model's act that can't resolve is refused; the server receives no tools/call for either. - act's results arrive as a direct call's do: a script's act gets the server's structuredContent, and a server's error reaches the model as an error result. - A dynamic tool keeps its own feature set: with two stateful feature sets, calls through act and direct calls in sequence are each sent their own set's state (the existing state-exchange test has one set, so it passed through the single-set fallback). - A classed dynamic tool called through act is observed with its class and, under an inputs grant by class, its arguments (the existing lifecycle test's dynamic tool was unclassed and its observer got metadata only). All four pass at 8aaee1ca, since they pin behaviour #799 already had. Each fails under a mutation of what it pins (Linux, at this branch's head): dropping the dispatch's dynamic refusal, dispatching act other than down the direct-call path, keeping a feature set only for offered tools, and keeping a class only for offered tools. Co-Authored-By: Claude Opus 5.5 --- test/mcpl-toolsets-wiring.test.ts | 151 +++++++++++++++++++++++++++++- 1 file changed, 150 insertions(+), 1 deletion(-) diff --git a/test/mcpl-toolsets-wiring.test.ts b/test/mcpl-toolsets-wiring.test.ts index 333fa7988..43c14c9ec 100644 --- a/test/mcpl-toolsets-wiring.test.ts +++ b/test/mcpl-toolsets-wiring.test.ts @@ -105,7 +105,10 @@ function resultFor(fw: Fw, toolUseId: string): string { const results = (fw.membrane.lastStream?.receivedToolResults ?? []).flat() as Array<{ toolUseId: string; content: unknown }>; const found = results.find((r) => r.toolUseId === toolUseId); assert.ok(found, `a result for ${toolUseId}`); - return typeof found.content === 'string' ? found.content : JSON.stringify(found.content); + const content = found.content; + if (typeof content === 'string') return content; + if (Array.isArray(content)) return content.map((b) => (typeof (b as { text?: unknown })?.text === 'string' ? (b as { text: string }).text : JSON.stringify(b))).join('\n'); + return JSON.stringify(content); } const notices = (fw: Fw) => windowText(fw.framework).filter((x) => x.includes('tool changes:')); @@ -505,3 +508,149 @@ test('a refused act\'s trace carries the server\'s text redacted, from a caller await server.close(); } }); + +test('a model\'s call of a dynamic tool by its listed name, and a model\'s act that can\'t resolve, are each refused, and nothing is sent', async () => { + const { server, showPeek } = await hintingServer({ content: [{ type: 'text', text: 'ran' }] }); + const fw = await connectedTo(server); + try { + server.peer.notify('tools/hints', showPeek()); + await waitFor(() => !fw.framework.getAllTools().some((t) => t.name === 'arc--peek'), 'peek is dynamic'); + await modelCalls(fw, [ + { type: 'tool_use', id: 'toolu_named', name: 'arc--peek', input: {} }, + { type: 'tool_use', id: 'toolu_unresolved', name: 'toolsets--act', input: { server: 'arc', tool: 'nosuch', args: {} } }, + ]); + assert.match(resultFor(fw, 'toolu_named'), /There's no tool "arc--peek" in your tool list, so nothing ran: peek is a dynamic tool of arc, called with toolsets--act/); + assert.match(resultFor(fw, 'toolu_unresolved'), /arc has no tool "nosuch"/); + assert.ok(!server.received.some((m) => m.method === 'tools/call'), 'nothing sent'); + } finally { + await fw.stop(); + await server.close(); + } +}); + +test('act\'s results arrive as a direct call\'s do: structured content to a script, and a server\'s error to the model', async () => { + let rev = 0; + const server = await startScriptedServer({ + advert: { ...ADVERT, featureSets: { ...(ADVERT.featureSets as Json), ...EVENTS } }, + tools: [STATUS, { ...PEEK, _meta: { 'mcpl/presence': 'dynamic', 'mcpl/visible': true } }], + listRev: () => rev, + on: (msg, peer) => { + if (msg.method === 'toolsets/list') { peer.reply(msg.id, { open: [], _meta: { 'mcpl/hints': { rev: ++rev, tools: [{ name: 'peek', visible: true }] } } }); return; } + if (msg.method === 'tools/call') { + const failing = (msg.params as Json).arguments && ((msg.params as Json).arguments as Json).fail === true; + peer.reply(msg.id, failing + ? { content: [{ type: 'text', text: 'the board is locked' }], isError: true } + : { content: [{ type: 'text', text: 'peeked' }], structuredContent: { seen: ['a', 'b'] } }); + return; + } + if (msg.id !== undefined) peer.error(msg.id, -32601, 'Method not found'); + }, + }); + const fw = await connectedTo(server, { codeExecution: { enabled: true } }); + try { + const code = 'const r = await tools["toolsets--act"]({ server: "arc", tool: "peek", args: {} }); text(JSON.stringify(r.structuredContent));'; + await modelCalls(fw, [ + { type: 'tool_use', id: 'toolu_script', name: 'code_execution', input: { code } }, + { type: 'tool_use', id: 'toolu_error', name: 'toolsets--act', input: { server: 'arc', tool: 'peek', args: { fail: true } } }, + ]); + assert.match(resultFor(fw, 'toolu_script'), /\{"seen":\["a","b"\]\}/); + assert.match(resultFor(fw, 'toolu_error'), /the board is locked/); + const results = (fw.membrane.lastStream?.receivedToolResults ?? []).flat() as Array<{ toolUseId: string; isError?: boolean }>; + assert.equal(results.find((r) => r.toolUseId === 'toolu_error')?.isError, true, 'an error result'); + } finally { + await fw.stop(); + await server.close(); + } +}); + +test('a dynamic tool keeps its own feature set: through act, each call is sent its own set\'s state', async () => { + let n = 0; + const calls: Json[] = []; + const server = await startScriptedServer({ + advert: { + ...ADVERT, + featureSets: { + 'arc.tools': { description: 'tools', uses: ['tools'], hostState: true }, + 'arc.games': { description: 'games', uses: ['tools'], hostState: true }, + }, + }, + tools: [ + { ...STATUS, _meta: { featureSet: 'arc.tools' } }, + { ...PEEK, _meta: { 'mcpl/presence': 'dynamic', 'mcpl/visible': true, featureSet: 'arc.games' } }, + ], + listRev: () => 0, + on: (msg, peer) => { + if (msg.method === 'toolsets/list') { peer.reply(msg.id, { open: [], _meta: { 'mcpl/hints': { rev: 1, tools: [{ name: 'peek', visible: true }] } } }); return; } + if (msg.method === 'tools/call') { + n++; + calls.push(msg.params as Json); + peer.reply(msg.id, { content: [{ type: 'text', text: 'ok' }], state: { checkpoint: `c${n}`, parent: null, data: { n } } }); + return; + } + if (msg.id !== undefined) peer.error(msg.id, -32601, 'Method not found'); + }, + }); + const fw = await connectedTo(server); + try { + const use = (id: string, name: string, input: Json) => createMockResponse([{ type: 'tool_use', id, name, input }] as never, 'tool_use'); + fw.membrane.pushResponse(use('toolu_s1', 'arc--status', {})); + fw.membrane.pushResponse(use('toolu_p1', 'toolsets--act', { server: 'arc', tool: 'peek', args: {} })); + fw.membrane.pushResponse(use('toolu_p2', 'toolsets--act', { server: 'arc', tool: 'peek', args: {} })); + fw.membrane.pushResponse(use('toolu_s2', 'arc--status', {})); + fw.membrane.pushResponse(createMockResponse([{ type: 'text', text: 'turn over' }])); + fw.framework.pushEvent({ type: 'external-message', source: 'test', content: [{ type: 'text', text: 'go' }], metadata: {}, triggerInference: true } as never); + await waitFor(() => calls.length === 4, 'four calls, one after another'); + assert.deepEqual(calls.map((c) => `${String(c.name)}:${JSON.stringify(c.state ?? null)}`), + ['status:null', 'peek:null', 'peek:{"n":2}', 'status:{"n":1}']); + } finally { + await fw.stop(); + await server.close(); + } +}); + +test('a classed dynamic tool called through act is observed with its class and, where the operator grants inputs, its arguments', async () => { + const classed = { ...PEEK, inputSchema: { type: 'object', properties: { x: { type: 'integer' } } }, _meta: { 'mcpl/presence': 'dynamic', 'mcpl/visible': true, 'mcpl/class': ['computer'] } }; + const server = await startScriptedServer({ + advert: ADVERT, + tools: [STATUS, classed], + listRev: () => 0, + on: (msg, peer) => { + if (msg.method === 'toolsets/list') { peer.reply(msg.id, { open: [], _meta: { 'mcpl/hints': { rev: 1, tools: [{ name: 'peek', visible: true }] } } }); return; } + if (msg.method === 'tools/call') { peer.reply(msg.id, { content: [{ type: 'text', text: 'ran' }] }); return; } + if (msg.id !== undefined) peer.error(msg.id, -32601, 'Method not found'); + }, + }); + const lifecycle: Json[] = []; + let observing = false; + const observer = await startScriptedServer({ + advert: { toolLifecycle: { observe: true, inputs: true }, featureSets: { 'obs.watch': { description: 'watch', uses: ['toolLifecycle.observe', 'toolLifecycle.inputs'] } } }, + tools: [], + on: (msg, peer) => { + if (msg.method === 'tools/lifecycle') { lifecycle.push(msg.params as Json); return; } + if (msg.id !== undefined) peer.error(msg.id, -32601, 'Method not found'); + }, + }); + const fw = await makeFramework([ + { id: 'arc', toolPrefix: 'arc', url: server.url, toolHints: true }, + { id: 'obs', toolPrefix: 'obs', url: observer.url, toolLifecycle: { observe: {}, inputs: { classes: ['computer'] } } }, + ] as McplServerConfig[]); + try { + await waitFor(() => fw.framework.getAllTools().some((t) => t.name === 'arc--status'), 'static tools listed'); + for (let tries = 0; !observing && tries < 50; tries++) { + const answer = await observer.peer.request('tools/observe', { rules: [{ match: {}, input: true }] }); + observing = !answer.error; + if (!observing) await new Promise((r) => setTimeout(r, 100)); + } + assert.ok(observing, 'the observer filter applied'); + await modelCalls(fw, [{ type: 'tool_use', id: 'toolu_act', name: 'toolsets--act', input: { server: 'arc', tool: 'peek', args: { x: 3 } } }]); + await waitFor(() => lifecycle.some((p) => p.phase === 'started'), 'the call observed'); + const started = lifecycle.find((p) => p.phase === 'started')!; + assert.equal(started.tool, 'arc--peek'); + assert.deepEqual(started.class, ['computer']); + assert.deepEqual(started.input, { x: 3 }); + } finally { + await fw.stop(); + await server.close(); + await observer.close(); + } +}); From f86c12834bf7ae8529e78d6cc70c9a6005f35644 Mon Sep 17 00:00:00 2001 From: Octavia-2412 Date: Sun, 25 Oct 2026 19:25:05 -0400 Subject: [PATCH 13/46] test(mcpl): the off path against 0.26.2's literal advertisement, and three end-to-end gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each written from what a server or an agent meets: - toolHints off: the capabilities the server is offered are compared with 0.26.2's literal object, not with the framework's own field; every tool is compared whole (name, description, input schema, in the server's order), not by name; and no toolsets/* request of any kind is sent. The literal is what an unchanged host offered in a run against #799's merge base (3b9c7dd0). - vector 9: the drop is seen through the server's own count of connections rather than a swallowed wait; after the reconnect's toolsets/list, a static call's answer (the server answers in order) stands in for the 200 ms sleep; and the instance is used through act before any look, since look re-reads toolsets/list itself. The test pins that an instance the server kept is neither opened again nor lost across the reconnect. - an open's result holds the server's own content (the board) beside the announcement. - an instance the server names is used as given (act with that name plays it), and opening what is open is adopted ("already open"), one open sent (RFC-014 §5.2's two MUSTs). All pass at 8aaee1ca: they pin what #799 already did. Mutations (Linux, at this branch's head): with no toolsets/list after a (re)connect, vector 9 fails at its wait; with the statement not applied, vector 9 passes, since the host keeps the instances it believed open across a reconnect, and a later test that reads an ended record fails instead. The server-named instance test still passes with both places that read an open's instance name mutated, since the server's hints carry the name too. Co-Authored-By: Claude Opus 5.5 --- test/mcpl-tool-hints.test.ts | 26 +++++++++++++++-------- test/mcpl-toolsets-e2e.test.ts | 38 +++++++++++++++++++++------------- 2 files changed, 41 insertions(+), 23 deletions(-) diff --git a/test/mcpl-tool-hints.test.ts b/test/mcpl-tool-hints.test.ts index ff131eaab..85c83148e 100644 --- a/test/mcpl-tool-hints.test.ts +++ b/test/mcpl-tool-hints.test.ts @@ -107,15 +107,23 @@ const push = (peer: ScriptedPeer, eventId: string, text: string, extra: Json = { test('toolHints off: the advertisement, tool list and traffic are as before', async () => { const t = await setup(() => false, { toolHints: undefined }); try { - const hostMcpl = t.server.peer.hostCaps?.mcpl as Json; - assert.ok(!('toolHints' in hostMcpl), 'no toolHints advertised'); - assert.ok(!('access' in hostMcpl), 'no access advertised'); - assert.deepEqual(hostMcpl, (t.fw.framework as unknown as { mcplHostCapabilities: Json }).mcplHostCapabilities, - 'the very capabilities every server is offered'); - const names = t.fw.framework.getAllTools().map((x) => x.name); - for (const tool of ['status', 'play', 'place', 'dig', 'peek']) assert.ok(names.includes(`arc--${tool}`), `arc--${tool} listed as before`); - assert.ok(!names.some((n) => n.startsWith('toolsets--')), 'no meta-tools'); - assert.ok(!t.methods().includes('toolsets/list'), 'no toolsets/list'); + assert.deepEqual(t.server.peer.hostCaps?.mcpl, { + version: '0.5', + eventCoalescing: { pushEvents: true, channelsIncoming: true, deferred: true, channelScopedPush: true, retryWindowMs: 3600000 }, + pushEvents: true, + contextHooks: { beforeInference: true }, + inferenceLifecycle: true, + toolLifecycle: { observe: true, inputs: true }, + inferenceRequest: { streaming: true }, + channels: { register: true, lifecycle: true, publish: true, incoming: true, streaming: true, acknowledge: true, typing: true }, + featureSets: true, + }, 'the capabilities 0.26.2 offers every server'); + const arcTools = t.fw.framework.getAllTools().filter((x) => x.name.startsWith('arc--')) + .map((x) => ({ name: x.name, description: x.description, inputSchema: x.inputSchema })); + assert.deepEqual(arcTools, TOOLS.map((x) => ({ name: `arc--${String(x.name)}`, description: x.description, inputSchema: x.inputSchema })), + 'every tool listed whole, in the server\'s order, as before'); + assert.ok(!t.fw.framework.getAllTools().some((x) => x.name.startsWith('toolsets--')), 'no meta-tools'); + assert.ok(!t.methods().some((m) => String(m).startsWith('toolsets/')), 'no toolsets/* request of any kind'); // A tools/hints request is a method this host doesn't handle, as before. const answer = await t.server.peer.request('tools/hints', { rev: 1, tools: [{ name: 'peek', visible: true }] }); assert.equal((answer.error as Json).code, -32601); diff --git a/test/mcpl-toolsets-e2e.test.ts b/test/mcpl-toolsets-e2e.test.ts index 1b23814a2..c6d5ac753 100644 --- a/test/mcpl-toolsets-e2e.test.ts +++ b/test/mcpl-toolsets-e2e.test.ts @@ -89,6 +89,7 @@ describe('RFC-014 host, end to end against the reference ToolsetServer', () => { const text = resultText(opened); assert.ok(!opened.isError, text); assert.match(text, /\[arcade\/game:g1\] Game g1 \(13\)/); + assert.match(text, /board of g1: 0 moves/, 'the server\'s own content for the open'); assert.match(text, /play\(point: string\) — Play a point on the board\./); assert.match(text, /toolsets--close \{server:"arcade", toolset:"game", instance:"g1"\} closes it\./); assert.equal(fw.framework.getAllTools().map((t) => t.name).join(','), before, 'the API tool list did not change'); @@ -143,22 +144,31 @@ describe('RFC-014 host, end to end against the reference ToolsetServer', () => { assert.doesNotMatch(again, /ended: resigned/, 'each ended id is told once'); }); - it('after a reconnect, an instance the server kept is adopted, not opened again (vector 9)', async () => { - const opens = () => srv.received.filter((m) => m === 'toolsets/open').length; - const lists = () => srv.received.filter((m) => m === 'toolsets/list').length; - const opensBefore = opens(); - const listsBefore = lists(); + it('after a reconnect, an instance the server kept is neither opened again nor lost (vector 9)', async () => { + const count = (method: string) => srv.received.filter((m) => m === method).length; + const opensBefore = count('toolsets/open'); + const connectsBefore = count('initialize'); + const listsBefore = count('toolsets/list'); srv.dropAll(); - await waitFor(() => (fw.framework.listMcplServers().find((s) => s.id === 'arcade')?.connected ?? false) === false, 'the drop seen', 5000).catch(() => {}); - await waitFor(() => fw.framework.listMcplServers().find((s) => s.id === 'arcade')?.connected === true, 'reconnected'); - // Its toolsets/list is read after the policy settles. - await waitFor(() => lists() > listsBefore, 'toolsets/list after the reconnect'); - await new Promise((r) => setTimeout(r, 200)); - const look = resultText(await callTool(fw.framework, 'toolsets--look', { server: 'arcade' })); - assert.match(look, /\[arcade\/game:g1\]/, 'g1 adopted'); - assert.equal(opens(), opensBefore, 'no toolsets/open was sent across the reconnect'); + await waitFor(() => count('initialize') > connectsBefore, 'a new connection'); + await waitFor(() => count('toolsets/list') > listsBefore, 'toolsets/list after the reconnect'); + await callTool(fw.framework, 'arcade--scores', {}); const act = await callTool(fw.framework, 'toolsets--act', { tool: 'resign', instance: 'g1', args: {} }); - assert.ok(!act.isError, resultText(act)); + assert.ok(!act.isError, `g1 still usable, before any look: ${resultText(act)}`); + assert.equal(count('toolsets/open'), opensBefore, 'no toolsets/open was sent across the reconnect'); + }); + + it('an instance the server names is used as given, and opening what is open adopts it (RFC-014 §5.2)', async () => { + const opened = resultText(await callTool(fw.framework, 'toolsets--open', { server: 'arcade', toolset: 'game' })); + const name = opened.match(/\[arcade\/game:([^\]]+)\]/)?.[1]; + assert.ok(name, `a named instance: ${opened}`); + const played = await callTool(fw.framework, 'toolsets--act', { tool: 'play', instance: name, args: { point: 'C3' } }); + assert.ok(!played.isError, resultText(played)); + assert.ok(calls.includes(`${name}:play:{"point":"C3"}`), 'the server played the instance it named'); + const opensBefore = srv.received.filter((m) => m === 'toolsets/open').length; + const again = resultText(await callTool(fw.framework, 'toolsets--open', { server: 'arcade', toolset: 'game', instance: name })); + assert.match(again, new RegExp(`\\[arcade/game:${name}\\][^\\n]*\\(already open\\)`)); + assert.equal(srv.received.filter((m) => m === 'toolsets/open').length, opensBefore + 1, 'one open sent, which the server adopted'); }); it('close reports what closing meant; a host\'s own close, whose result the agent may not see, is told as a notice too', async () => { From b3092aa4fb546092914d49e3f3944880cded55ef Mon Sep 17 00:00:00 2001 From: Octavia-2412 Date: Sun, 25 Oct 2026 19:26:53 -0400 Subject: [PATCH 14/46] test(mcpl): a manifest change that removes a toolset with instances, or withdraws toolHints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC-014 §5.1's requirements had no test for withdrawing toolHints by a manifest change, and vector 33's "closes its instances" removed only a toolset declared without instances. - A toolset with two open instances removed by a manifest change: each instance is told closed, and look shows none. - toolHints withdrawn by a manifest change: the agent is told the server no longer presents toolsets and its tools are called directly now, and every tool is listed. Both pin what #799 already did. The first passes at 8aaee1ca; the second fails there at the withdrawal note's wording, which an earlier commit here changes. Co-Authored-By: Claude Opus 5.5 --- test/mcpl-tool-hints.test.ts | 39 ++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/test/mcpl-tool-hints.test.ts b/test/mcpl-tool-hints.test.ts index 85c83148e..984db9c00 100644 --- a/test/mcpl-tool-hints.test.ts +++ b/test/mcpl-tool-hints.test.ts @@ -372,6 +372,45 @@ test('vector 33: a manifest change that removes a toolset closes its instances', } finally { await t.done(); } }); +test('a manifest change that removes a toolset closes each of its open instances', async () => { + let advert: Json = ADVERT; + const t = await setup((msg, peer, state) => { + if (msg.method === 'mcpl/manifest') { peer.reply(msg.id, { version: '0.5', pushEvents: true, ...advert }); return true; } + if (msg.method === 'toolsets/open') { + const instance = String((msg.params as Json).instance); + state.open = [...state.open, { toolset: 'game', instance, title: instance.toUpperCase() }]; + peer.reply(msg.id, { toolset: 'game', instance, title: instance.toUpperCase(), _meta: { 'mcpl/hints': state.hint([{ name: 'play', instance, visible: true }]) } }); + return true; + } + return false; + }); + try { + await callTool(t.fw.framework, 'toolsets--open', { server: 'arc', toolset: 'game', instance: 'g1' }); + await callTool(t.fw.framework, 'toolsets--open', { server: 'arc', toolset: 'game', instance: 'g2' }); + advert = { ...ADVERT, toolsets: { build: (ADVERT.toolsets as Json).build } }; + t.server.peer.notify('mcpl/manifestChanged', { revision: 'r2', domains: ['toolsets'] }); + await waitFor(() => ['[arc/game:g1] closed', '[arc/game:g2] closed'].every((line) => windowText(t.fw.framework).some((x) => x.includes(line))), 'both closes told'); + assert.doesNotMatch(await look(t.fw), /\[arc\/game:/); + } finally { await t.done(); } +}); + +test('a manifest change that withdraws toolHints makes every tool static, and the agent is told', async () => { + let advert: Json = ADVERT; + const t = await setup((msg, peer) => { + if (msg.method === 'mcpl/manifest') { peer.reply(msg.id, { version: '0.5', pushEvents: true, ...advert }); return true; } + return false; + }); + try { + const listed = () => t.fw.framework.getAllTools().map((x) => x.name); + assert.ok(!listed().includes('arc--peek'), 'a dynamic tool, while hints apply'); + const { toolHints: _withdrawn, toolsets: _gone, ...rest } = ADVERT; + advert = rest; + t.server.peer.notify('mcpl/manifestChanged', { revision: 'r2', domains: ['toolsets'] }); + await waitFor(() => windowText(t.fw.framework).some((x) => x.includes('arc no longer presents toolsets: its tools are called directly now, by name, not through toolsets--act.')), 'the withdrawal told'); + await waitFor(() => ['play', 'place', 'dig', 'peek'].every((tool) => listed().includes(`arc--${tool}`)), 'every tool listed'); + } finally { await t.done(); } +}); + test('vector 34: a server with toolHints and no declared toolsets states its dynamic tools in toolsets/list', async () => { const advert: Json = { toolHints: true, featureSets: ADVERT.featureSets }; const tools = [TOOLS[0]!, TOOLS[4]!]; From 51218fe0d7efc7e4ff42691a39e74763faccd35d Mon Sep 17 00:00:00 2001 From: Octavia-2412 Date: Sun, 25 Oct 2026 21:31:28 -0400 Subject: [PATCH 15/46] fix(mcpl): until a move applies, a tool is called wherever the model has been shown it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC-014 §4.2: moves "are announced like any hint change", and "Until it applies the move, the tool keeps working where it is." Presence follows each tools/list as it's read, and what the agent is told at that read (the view's announcements, look's catalog) follows it; the model's tool list follows at the framework's next rebuild. Both checks that decide where a tool is called read presence alone, so in between a tool a list moved to dynamic was refused by the listed name the tool list still offered. Now a call is taken wherever the model has been shown the tool: in the tool list last published, or in the latest announcement, presence (ToolPresence.shownAs). A call by its listed name is refused only where both say dynamic, and toolsets--act's "not a dynamic tool" only where both say static. The framework answers where its published list put a tool through one hub dep, published(serverId, tool): offered (static), listed but not offered (dynamic), or not published. This only widens what's taken: nothing the base took is refused. A tool newly put in a closed undeclared group stays callable by its listed name until the rebuild publishes the move (§4.2's "keeps working where it is"). look naming a tool shows the definition act would check (actKey, act's own key rule: an instance's own, else the toolset's, else the listed one) wherever act would take the tool, and the listed one elsewhere, so a hint's definition that act checks is the one look shows, in the window too. toolStatus's static line says the tool is "called by name", which holds where presence says static whether or not the tool list holds it yet. The agent is told where a tool went once a rebuild has published the list that shows it. The rebuild told each server's moves as it listed that server, before publishing, so with more than one server the note "peek is in your tool list now" could come while the published list didn't offer peek yet. Now the moves a rebuild saw as it listed a server are told after it publishes, those still pending then. A rebuild that doesn't publish (a newer one has) leaves them pending for one that does. A move a list has undone since the rebuild listed the server isn't told, and asks for another rebuild, so the tool list follows the undoing. The README says where a moved tool is called and when the agent is told, and the controller's header where it's called. Tests: - a tool a list moves to static, with the rebuild held: act and its listed name both call it; after the rebuild its name does and act refuses it ("called by name, as arc--peek"); - a tool a list moves to dynamic, with the rebuild held: its listed name and act both call it; after the rebuild act does and its name is refused toward act; - a static tool the tool list hasn't published is refused through act; - look names a tool by a hint's definition while act would take it, and by the listed one once act wouldn't; - where no tool list has published a tool, presence decides; - with a second server whose tools/list the rebuild waits on, a move is not told until the rebuild publishes, and a call by name meanwhile goes through; then it's told once, and the tool list shows it; - a rebuild that never publishes (a newer one, started by a third server's connect, publishes first) leaves its move to the one that publishes, which tells it once; - a move a list undoes while the rebuild waits on the second server is never told, and the tool list follows the undoing. Mutations, each failing at least one of them: the published list alone deciding where a tool is called, and presence alone (the first two); the framework answering for names it never published (the third); look keying a tool off the view alone, and look showing act's definition whether or not act would take it (the fourth); the moves told as each server is listed, as before this change, and taken as each server is listed (the two waiting on a second server); no rebuild asked for an undone move, and every listed move told whether or not it's still pending (the undoing test). Co-Authored-By: Claude Opus 5.5 --- README.md | 12 ++- src/framework.ts | 24 +++-- src/mcpl/tool-presence.ts | 28 ++++-- src/mcpl/toolset-host.ts | 23 +++-- src/mcpl/toolsets-module.ts | 19 +++- test/mcpl-tool-hints.test.ts | 188 +++++++++++++++++++++++++++++++++++ 6 files changed, 265 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 5d1e0f198..6db5b8f8e 100644 --- a/README.md +++ b/README.md @@ -464,9 +464,12 @@ framework registers a `toolsets` module with four constant tools: `server` can be left out when only one server has the name. Hint changes apply as their message is read, in `rev` order, whatever becomes of the message (a refused push, a result that came after its timeout). A move between static and -dynamic takes effect when the server's `tools/list` states it: `toolsets--act` -follows it then, and the tool list at its next rebuild, which the host asks for -at once when its own re-read (after a reconnect or a lost `rev`) made the move. +dynamic takes effect when the server's `tools/list` states it, and the tool +list follows at its next rebuild, which the host asks for at once when its own +re-read (after a reconnect or a lost `rev`) made the move. Until then the tool +is called at either place, where the tool list shows it or where the server's +latest list put it (RFC-014 §4.2: it keeps working where it was), and the agent +is told where it went once that rebuild has published the list. A skipped rev re-reads `tools/list` and then `toolsets/list`, which the host also reads after every (re)connect, adopting what the server kept open and never re-opening anything. @@ -494,7 +497,8 @@ stored `afterUser` injection (namespace `mcpl-toolsets`), at that agent's next start. A dynamic tool called by its prefixed name is refused, pointing at -`toolsets--act`. Calls to an undeclared toolset's tools are refused while the +`toolsets--act`, unless the tool list still offers it while a move waits for +its rebuild. Calls to an undeclared toolset's tools are refused while the host has that group closed (RFC-014 §5.4). Projection and surface mode are not implemented: a `surface` server's static tools are listed like any server's. diff --git a/src/framework.ts b/src/framework.ts index 08676898b..e38f45c35 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -762,8 +762,8 @@ import type { GateEntryRef, WakeLine } from './gate/types.js'; import { EPHEMERAL_USAGE_NAME, UsageTracker, persistedUsageProblem, type PersistedUsageState } from './usage/usage-tracker.js'; import type { SessionUsageSnapshot, UsageUpdatedEvent } from './usage/types.js'; import { McplProtocolVersionError, McplRequestError, McplUnreapedLaunchError, stderrTailOf, toolsetDeclarationsOf, withStderr, type McplConnectionCloseInfo, type McplServerConnection } from './mcpl/server-connection.js'; -import { ToolsetHub, TOOLSETS_MODULE, META_TOOL_NAMES } from './mcpl/toolset-host.js'; -import { isEmptyChange } from './mcpl/tool-presence.js'; +import { ToolsetHub, TOOLSETS_MODULE, META_TOOL_NAMES, type ToolsetController } from './mcpl/toolset-host.js'; +import { isEmptyChange, type Presence } from './mcpl/tool-presence.js'; import { ToolsetsModule, resolveAct, withLookHint, type TakenChange } from './mcpl/toolsets-module.js'; import { ModernMcpConnection } from './mcpl/modern-connection.js'; import { WITHHELD_TEXT, asText, credentialRedactor, redactUrl, redactServerText, resolveServerBinding, serverConfigProblems, serverCredentialPatterns, serverTextRedactor, type CredentialPattern } from './mcpl/protocol-family.js'; @@ -27646,12 +27646,19 @@ export class AgentFramework { /** RFC-014: every toolHints server's presence, made on first use. */ private toolsets(): ToolsetHub { - return (this.toolsetHub ??= new ToolsetHub({ + if (this.toolsetHub) return this.toolsetHub; + const listedName = (serverId: string, tool: string) => `${this.mcplServerConfigs.get(serverId)?.toolPrefix ?? `mcpl--${serverId}`}--${tool}`; + return (this.toolsetHub = new ToolsetHub({ notify: (serverId, text, opts) => this.recordToolsetNotice(serverId, text, opts), canNotify: (serverId) => CapabilityGrant.of(this.mcplServerRegistry?.getServer(serverId)).has('pushEvents'), refreshTools: (serverId) => this.handleToolsListChanged(serverId), log: (line) => console.error(`[mcpl] ${line}`), - listedName: (serverId, tool) => `${this.mcplServerConfigs.get(serverId)?.toolPrefix ?? `mcpl--${serverId}`}--${tool}`, + listedName, + published: (serverId, tool) => { + const name = listedName(serverId, tool); + if (this.mcplToolServers?.get(name) !== serverId) return undefined; + return this.mcplTools.some((t) => t.name === name) ? 'static' : 'dynamic'; + }, })); } @@ -29766,6 +29773,7 @@ export class AgentFramework { // Each server's close count as its listing began. const closesAtListing = new Map(); const repeats: Array<{ serverId: string; tools: string[] }> = []; + const movesListed = new Map }>(); for (const server of [...this.mcplServerRegistry.getAllServers()]) { const config = this.mcplServerConfigs.get(server.id); @@ -29784,10 +29792,11 @@ export class AgentFramework { // a direct call of the tool would be. The list was read through the // server's tap, so its presence is current. // The static↔dynamic moves the lists made since the last rebuild - // reach the tool list here (§4.2), and the agent is told where each - // tool went, when a notice can be written. + // reach the tool list here (§4.2). The agent is told where each tool + // went once this refresh publishes the list, and not by a refresh that + // doesn't: the moves stay pending for one that does. const presence = this.toolsetHub?.get(server.id); - if (presence?.presence.active) presence.applyMoves(); + if (presence?.presence.active) movesListed.set(server.id, { controller: presence, moves: presence.presence.moves() }); const dynamic = (name: string): boolean => !!presence?.presence.active && presence.presence.presenceOf(name) === 'dynamic'; for (const tool of result.tools) { if (!isToolAllowed(tool.name, config)) continue; @@ -29918,6 +29927,7 @@ export class AgentFramework { this.mcplToolClasses = toolClasses; this.mcplToolServers = toolServers; this.mcplListedServers = listedServers; + for (const [serverId, { controller, moves }] of movesListed) if (listedServers.has(serverId)) controller.applyMoves(moves); for (const { serverId, tools: repeatedTools } of repeats) { const one = repeatedTools.length === 1; const listed = repeatedTools.map((name) => logValue(name)).join(', '); diff --git a/src/mcpl/tool-presence.ts b/src/mcpl/tool-presence.ts index 880b6cc48..25294a821 100644 --- a/src/mcpl/tool-presence.ts +++ b/src/mcpl/tool-presence.ts @@ -120,7 +120,7 @@ export class ToolPresence { private defs = new Map>(); /** `ended` ids told to the agent, kept while the server still reports them (RFC §5.3). */ private toldEnded = new Set(); - /** Moves a tools/list made that the model's tool list doesn't show yet: told at its next rebuild. */ + /** Moves a tools/list made that the model's tool list doesn't show yet: told once a rebuild has published them. */ private pendingMoves = new Map(); /** Digests of definitions the model has been shown with their first sentence. */ readonly shown = new Set(); @@ -133,6 +133,8 @@ export class ToolPresence { private readonly allowed: (tool: string) => boolean = () => true, /** How a static tool is named in the API tool list, for `look`. */ private readonly listedName: (tool: string) => string = (tool) => tool, + /** Where the model's published tool list last put a tool, if it's there (shownAs). */ + private readonly published: (tool: string) => Presence | undefined = () => undefined, ) {} // ── connection lifecycle ── @@ -256,6 +258,15 @@ export class ToolPresence { return this.listedPresence(name); } + /** RFC §4.2: "Until it applies the move, the tool keeps working where it + * is." Whether the model has been shown a tool at `place`: in the tool list + * last published, or in the latest announcement (its presence). Until a + * rebuild applies a move the two differ, and a call is taken at either. */ + shownAs(name: string, place: Presence): boolean { + if (!this.active) return place === 'static'; + return this.published(name) === place || this.listedPresence(name) === place; + } + private listedPresence(name: string): Presence { if (this.toolsetOf(name)) return 'dynamic'; return this.tools.get(name)?._meta?.['mcpl/presence'] === 'dynamic' ? 'dynamic' : 'static'; @@ -564,10 +575,15 @@ export class ToolPresence { return this.pendingMoves.size > 0; } - /** At a rebuild of the model's tool list (RFC §4.2): the static↔dynamic moves the lists made since the last one. */ - applyMoves(): Array<{ name: string; to: Presence }> { - const out = [...this.pendingMoves].map(([name, to]) => ({ name, to })); - this.pendingMoves.clear(); + /** The static↔dynamic moves the lists made since the last rebuild, still to be shown. */ + moves(): Array<{ name: string; to: Presence }> { + return [...this.pendingMoves].map(([name, to]) => ({ name, to })); + } + + /** At a rebuild of the model's tool list (RFC §4.2): those of `moves` still pending, now shown. */ + applyMoves(moves = this.moves()): Array<{ name: string; to: Presence }> { + const out = moves.filter(({ name, to }) => this.pendingMoves.get(name) === to); + for (const { name } of out) this.pendingMoves.delete(name); return out; } @@ -694,7 +710,7 @@ export class ToolPresence { /** Where a tool can be called from now, for `look` naming it. */ toolStatus(tool: string): string { - if (this.presenceOf(tool) === 'static') return `static — in your tool list as ${this.listedName(tool)}`; + if (this.presenceOf(tool) === 'static') return `static — called by name, as ${this.listedName(tool)}`; const where = [...this.view().entries()].filter(([, e]) => e.tools.has(tool)).map(([k]) => k); const ts = this.toolsetOf(tool); return where.length diff --git a/src/mcpl/toolset-host.ts b/src/mcpl/toolset-host.ts index 644fc66fe..c2809fb2c 100644 --- a/src/mcpl/toolset-host.ts +++ b/src/mcpl/toolset-host.ts @@ -7,7 +7,8 @@ * * Hint changes apply as their message is read (RFC §6.1); a static↔dynamic * move takes effect when a tools/list states it (§4.1), and the model's tool - * list shows it at its next rebuild (§4.2). The connection's read tap hands + * list shows it at its next rebuild; until then the tool is called at either + * place (§4.2). The connection's read tap hands * the controller every result of a hinted request (awaited or not, while no * more than 1,000 hinted requests await an answer), each tools/list whole, every push/event and every tools/hints, of the * current transport, synchronously and in arrival order, before anything @@ -30,7 +31,7 @@ import { redactServerText } from './protocol-family.js'; import { logDetail, logValueRedacted } from './log-value.js'; import { ToolPresence, emptyChange, isEmptyChange, keyOf, - type ChangeSet, type MetaToolNames, + type ChangeSet, type MetaToolNames, type Presence, } from './tool-presence.js'; import type { HintedTool } from './tool-signature.js'; @@ -69,6 +70,11 @@ export interface ToolsetHostDeps { log(line: string): void; /** How a static tool is named in the tool list (`prefix--tool`). */ listedName(serverId: string, tool: string): string; + /** Where the model's published tool list last put a server's tool: + * offered in it (static), listed but not offered (dynamic), or undefined + * where it isn't published. RFC §4.2: until its move applies, a tool + * keeps working where it is. */ + published?(serverId: string, tool: string): Presence | undefined; } /** One server's RFC-014 state and traffic. Lives as long as the server's @@ -99,6 +105,7 @@ export class ToolsetController { META_TOOL_NAMES, (tool) => isToolAllowed(tool, this.config), (tool) => deps.listedName(serverId, tool), + (tool) => deps.published?.(serverId, tool), ); } @@ -369,10 +376,11 @@ export class ToolsetController { * A dynamic tool called by its namespaced name, as if it were listed (a * model imitating an earlier call, a module): refused, so every call into * a toolset goes through act, which knows its instance and gates closed - * groups (RFC §5.4, §5.5). Null for any other tool. + * groups (RFC §5.4, §5.5). Null for any other tool, including one the + * model was last shown as static (shownAs: RFC §4.2, until its move applies). */ dynamicCallRefusal(tool: string, calledAs: string): string | null { - if (!this.presence.active || !this.presence.tool(tool) || this.presence.presenceOf(tool) !== 'dynamic') return null; + if (!this.presence.active || !this.presence.tool(tool) || this.presence.shownAs(tool, 'static')) return null; return `There's no tool "${calledAs}" in your tool list, so nothing ran: ${tool} is a dynamic tool of ${this.serverId}, ` + `called with ${META_TOOL_NAMES.act} {server:"${this.serverId}", tool:"${tool}", args}. ${this.presence.toolStatus(tool)}.`; } @@ -630,9 +638,10 @@ export class ToolsetController { this.deliver(this.presence.diff(before, this.presence.view())); } - /** At a rebuild of the model's tool list (RFC §4.2), tell where the tools the list moved went. */ - applyMoves(): void { - const moved = this.presence.applyMoves(); + /** At a rebuild of the model's tool list (RFC §4.2), tell where the tools it moved went: `moves`, those still pending, or every one pending. A move a list has undone since the rebuild listed the server asks for another rebuild, so the tool list follows. */ + applyMoves(moves?: Array<{ name: string; to: Presence }>): void { + const moved = this.presence.applyMoves(moves); + if (moves?.some(({ name, to }) => this.presence.presenceOf(name) !== to)) this.deps.refreshTools(this.serverId); if (!moved.length) return; const cs = emptyChange(); cs.notes.push(...moved.map(({ name, to }) => to === 'dynamic' diff --git a/src/mcpl/toolsets-module.ts b/src/mcpl/toolsets-module.ts index 099809a9f..137ed0085 100644 --- a/src/mcpl/toolsets-module.ts +++ b/src/mcpl/toolsets-module.ts @@ -26,7 +26,7 @@ import type { EventResponse, Module, ModuleContext, ModuleContextInjection, Proc import type { ProcessEvent, ToolCall, ToolDefinition, ToolResult } from '../types/events.js'; import { unknownToolMessage } from '../modules/unknown-tool.js'; import { META_TOOL_NAMES, TOOLSETS_MODULE, type ToolsetController, type ToolsetHub } from './toolset-host.js'; -import { keyOf, type ChangeSet } from './tool-presence.js'; +import { keyOf, type ChangeSet, type ToolPresence } from './tool-presence.js'; import { checkArgs, jsonText, renderToolDefinition, type HintedTool } from './tool-signature.js'; import { McplRequestError } from './server-connection.js'; @@ -209,6 +209,14 @@ export type ActTarget = { ok: true; serverId: string; tool: string; instance?: s * several are open, a group the host has closed (RFC §5.4), and arguments * that don't fit the definition in force for that instance (RFC §6.1). */ +const hasInstances = (p: ToolPresence, ts: string | undefined): boolean => ts !== undefined && p.isDeclared(ts) && !!p.declared[ts]!.instances; + +/** The key whose definition act checks a call of `tool` against (RFC §6.1): an instance's own, else the toolset's, else the listed one. */ +function actKey(p: ToolPresence, tool: string, instance: string | undefined): string { + const ts = p.toolsetOf(tool); + return ts ? keyOf(ts, hasInstances(p, ts) ? instance : undefined) : keyOf(''); +} + export function resolveAct(hub: ToolsetHub, input: unknown): ActTarget { try { const args = isObj(input) ? input : {}; @@ -217,13 +225,13 @@ export function resolveAct(hub: ToolsetHub, input: unknown): ActTarget { const c = owner(hub, args.server, (x) => !!x.presence.tool(tool), `a tool "${tool}"`); const p = c.presence; if (!p.tool(tool)) fail(`${c.serverId} has no tool "${tool}": ${META_TOOL_NAMES.look} {server:"${c.serverId}"} shows what's available`); - if (p.presenceOf(tool) === 'static') fail(`${tool} is not a dynamic tool: ${p.toolStatus(tool)}. Call it there.`); + if (!p.shownAs(tool, 'dynamic')) fail(`${tool} is not a dynamic tool: ${p.toolStatus(tool)}. Call it there.`); if (args.args !== undefined && !isObj(args.args)) fail('args must be an object of the tool\'s arguments'); const callArgs = (args.args ?? {}) as Record; let instance = str(args.instance); const ts = p.toolsetOf(tool); const declared = ts !== undefined && p.isDeclared(ts); - const withInstances = declared && !!p.declared[ts!]!.instances; + const withInstances = hasInstances(p, ts); if (withInstances && !instance) { // RFC §5.5: the instance rides in _meta; with exactly one open, it's that one. const open = p.openRecords(ts); @@ -237,7 +245,7 @@ export function resolveAct(hub: ToolsetHub, input: unknown): ActTarget { // RFC §6.1: arguments are checked against the definition in force for // that instance: its own from hints (the legal moves of one game), else // the toolset's, else the listed one. - const key = ts ? keyOf(ts, withInstances ? instance : undefined) : keyOf(''); + const key = actKey(p, tool, instance); const problem = checkArgs(p.defOf(key, tool).inputSchema, callArgs); if (problem) { fail(`Invalid arguments for ${tool} in ${p.label(key)}: ${problem}. Nothing was sent; ` + @@ -475,7 +483,8 @@ export class ToolsetsModule implements Module { if (!p.tool(tool)) fail(`${c.serverId} has no tool "${tool}": ${META_TOOL_NAMES.look} {server:"${c.serverId}"} shows what's available`); const ts = p.toolsetOf(tool); const where = [...p.view().entries()].filter(([, e]) => e.tools.has(tool)).map(([k]) => k); - const key = instance && ts ? keyOf(ts, instance) : where[0]; + // The definition act would check, wherever act would take the tool. + const key = !p.shownAs(tool, 'dynamic') ? undefined : instance && ts ? keyOf(ts, instance) : where[0] ?? actKey(p, tool, undefined); const def = key ? p.defOf(key, tool) : p.tool(tool)!; return textResult(`${renderToolDefinition(def)}\n${p.toolStatus(tool)}`); } diff --git a/test/mcpl-tool-hints.test.ts b/test/mcpl-tool-hints.test.ts index 984db9c00..17ea8c252 100644 --- a/test/mcpl-tool-hints.test.ts +++ b/test/mcpl-tool-hints.test.ts @@ -1704,3 +1704,191 @@ test('a restart under the same id with toolHints keeps what the host told: an en assert.equal(told(), 1, 'told once, across the restart'); } finally { await t.done(); } }); + +const withPresence = (name: string, presence: 'static' | 'dynamic') => TOOLS.map((x) => (x.name === name ? { ...x, _meta: { ...(x._meta as Json | undefined), 'mcpl/presence': presence } } : x)); + +async function movingServer(moved: Json[]) { + let phase: 'off' | 'move' | 'hold' = 'off'; + let release: (() => void) | null = null; + const t = await setup((msg, peer, state) => { + if (msg.method !== 'tools/list' || phase === 'off') return false; + const answer = () => peer.reply(msg.id, { tools: moved, _meta: { 'mcpl/rev': state.rev } }); + if (phase === 'move') { + phase = 'hold'; + answer(); + } else { + release = answer; + } + return true; + }); + const moveAndHold = async () => { + phase = 'move'; + t.state.next(); + t.server.peer.notify('tools/hints', t.state.hint([])); + await waitFor(() => release !== null, 'the rebuild\'s tools/list asked for'); + }; + return { t, moveAndHold, release: () => release?.() }; +} + +test('RFC-014 §4.2: a tool a list moves to static is called both ways until the tool list shows it, then by its listed name', async () => { + const { t, moveAndHold, release } = await movingServer(withPresence('peek', 'static')); + try { + await moveAndHold(); + const viaAct = await callTool(t.fw.framework, 'toolsets--act', { server: 'arc', tool: 'peek', args: {} }); + assert.equal(resultText(viaAct), 'peek done', 'act still calls it, where the tool list put it'); + const byName = await t.fw.framework.executeToolCall({ id: 'early', name: 'arc--peek', input: {} } as never); + assert.equal(resultText(byName), 'peek done', 'its listed name calls it, where the server\'s latest list put it'); + release(); + await waitFor(() => t.fw.framework.getAllTools().some((x) => x.name === 'arc--peek'), 'the rebuild shows peek'); + assert.equal(resultText(await t.fw.framework.executeToolCall({ id: 'late', name: 'arc--peek', input: {} } as never)), 'peek done'); + assert.equal(resultText(await callTool(t.fw.framework, 'toolsets--act', { server: 'arc', tool: 'peek', args: {} })), 'peek is not a dynamic tool: static — called by name, as arc--peek. Call it there.'); + } finally { + release(); + await t.done(); + } +}); + +test('RFC-014 §4.2: a tool a list moves to dynamic is called both ways until the tool list drops it, then through act', async () => { + const { t, moveAndHold, release } = await movingServer(withPresence('status', 'dynamic')); + try { + await moveAndHold(); + const byName = await t.fw.framework.executeToolCall({ id: 'early', name: 'arc--status', input: {} } as never); + assert.equal(resultText(byName), 'status done', 'its listed name still calls it, where the tool list put it'); + assert.equal(resultText(await callTool(t.fw.framework, 'toolsets--act', { server: 'arc', tool: 'status', args: {} })), 'status done', 'act calls it, where the server\'s latest list put it'); + release(); + await waitFor(() => !t.fw.framework.getAllTools().some((x) => x.name === 'arc--status'), 'the rebuild drops status'); + const late = await t.fw.framework.executeToolCall({ id: 'late', name: 'arc--status', input: {} } as never); + assert.match(String(late.error), /status is a dynamic tool of arc, called with toolsets--act/); + assert.equal(resultText(await callTool(t.fw.framework, 'toolsets--act', { server: 'arc', tool: 'status', args: {} })), 'status done'); + } finally { + release(); + await t.done(); + } +}); + +test('RFC-014 §4.2: a static tool the tool list hasn\'t published isn\'t taken through act', async () => { + const newt = { name: 'newt', description: 'A newt.', inputSchema: { type: 'object', properties: {} } }; + const { t, moveAndHold, release } = await movingServer([...withPresence('peek', 'static'), newt]); + try { + await moveAndHold(); + const viaAct = await callTool(t.fw.framework, 'toolsets--act', { server: 'arc', tool: 'newt', args: {} }); + assert.ok(viaAct.isError); + assert.equal(resultText(viaAct), 'newt is not a dynamic tool: static — called by name, as arc--newt. Call it there.'); + release(); + await waitFor(() => t.fw.framework.getAllTools().some((x) => x.name === 'arc--newt'), 'the rebuild publishes newt'); + } finally { + release(); + await t.done(); + } +}); + +test('RFC-014 §4.2: look names a tool by the definition act would check, wherever act would take it', async () => { + const { t, moveAndHold, release } = await movingServer(withPresence('peek', 'static')); + try { + const hinted = { name: 'peek', visible: false, definition: { description: 'Peek, from a hint.', inputSchema: { type: 'object', properties: { far: { type: 'boolean' } } } } }; + t.server.peer.notify('tools/hints', t.state.hint([hinted])); + // The server's own statement keeps restating the definition (RFC §6.1), as the move's recovery reads it. + t.state.statement.push(hinted); + await roundTrip(t); + await moveAndHold(); + assert.match(resultText(await callTool(t.fw.framework, 'toolsets--look', { server: 'arc', tool: 'peek' })), /^peek\(far\?: boolean\)[\s\S]*Peek, from a hint\./, 'act would still take it, so look shows the hint\'s definition'); + release(); + await waitFor(() => t.fw.framework.getAllTools().some((x) => x.name === 'arc--peek'), 'the rebuild shows peek'); + assert.match(resultText(await callTool(t.fw.framework, 'toolsets--look', { server: 'arc', tool: 'peek' })), /Peek around\./, 'act wouldn\'t, so look shows the listed one'); + } finally { + release(); + await t.done(); + } +}); + +/** arc, whose tools/list moves peek to static once `move` is called, and a second server, slow, whose tools/list can be held. */ +async function arcAndSlow() { + let moved = false; + const t = await setup((msg, peer, state) => { + if (msg.method !== 'tools/list' || !moved) return false; + peer.reply(msg.id, { tools: withPresence('peek', 'static'), _meta: { 'mcpl/rev': state.rev } }); + return true; + }); + let holdNext = false; + let held: (() => void) | null = null; + const slow = await startScriptedServer({ + advert: { featureSets: ADVERT.featureSets }, + tools: [TOOLS[0]!], + onList: (msg, peer) => { + if (!holdNext) return false; + holdNext = false; + held = () => peer.reply(msg.id, { tools: [TOOLS[0]!] }); + return true; + }, + }); + await t.fw.framework.connectMcplServer({ id: 'slow', toolPrefix: 'slow', url: slow.url }); + await waitFor(() => t.fw.framework.getAllTools().some((x) => x.name === 'slow--status'), 'slow listed'); + const move = async () => { + moved = true; + holdNext = true; + t.state.next(); + t.server.peer.notify('tools/hints', t.state.hint([])); + await waitFor(() => held !== null, 'the rebuild waits on slow'); + }; + const unmove = () => { moved = false; }; + const release = () => { const h = held; held = null; h?.(); }; + const toldMoved = () => notices(t.fw).filter((x) => x.includes('peek is in your tool list now')).length; + return { t, slow, move, unmove, release, toldMoved, async done() { release(); await t.done(); await slow.close(); } }; +} + +test('RFC-014 §4.2: a move is told once the rebuild has published every server\'s list, and a call by name before then still goes through', async () => { + const s = await arcAndSlow(); + try { + await s.move(); + assert.equal(s.toldMoved(), 0, 'not told while the rebuild waits on another server'); + const byName = await s.t.fw.framework.executeToolCall({ id: 'early', name: 'arc--peek', input: {} } as never); + assert.equal(resultText(byName), 'peek done', 'the server\'s latest list put it there'); + s.release(); + await waitFor(() => s.toldMoved() === 1, 'told once the rebuild publishes'); + assert.ok(s.t.fw.framework.getAllTools().some((x) => x.name === 'arc--peek'), 'the tool list shows it'); + } finally { await s.done(); } +}); + +test('RFC-014 §4.2: a rebuild that never publishes leaves its moves to the one that does, which tells each once', async () => { + const s = await arcAndSlow(); + const third = await startScriptedServer({ advert: { featureSets: ADVERT.featureSets }, tools: [TOOLS[0]!] }); + try { + await s.move(); + assert.equal(s.toldMoved(), 0, 'not told by the rebuild that waits'); + await s.t.fw.framework.connectMcplServer({ id: 'third', toolPrefix: 'third', url: third.url }); + await waitFor(() => s.t.fw.framework.getAllTools().some((x) => x.name === 'third--status' || x.name === 'arc--peek'), 'the newer rebuild published'); + await waitFor(() => s.toldMoved() === 1, 'told by the rebuild that published'); + s.release(); + await waitFor(() => !(s.t.fw.framework as unknown as { mcplToolRefreshInFlight: boolean }).mcplToolRefreshInFlight, 'the older rebuild ended'); + await roundTrip(s.t); + await nextMacrotask(); + assert.equal(s.toldMoved(), 1, 'told once'); + } finally { + await s.done(); + await third.close(); + } +}); + +test('RFC-014 §4.2: a move a list undoes while the rebuild waits on another server is never told, and the tool list follows the undoing', async () => { + const s = await arcAndSlow(); + try { + await s.move(); + s.unmove(); + const lists = s.t.methods().filter((m) => m === 'toolsets/list').length; + s.t.state.next(); + s.t.server.peer.notify('tools/hints', s.t.state.hint([])); + await waitFor(() => s.t.methods().filter((m) => m === 'toolsets/list').length > lists, 'the undoing list read'); + s.release(); + await waitFor(() => !s.t.fw.framework.getAllTools().some((x) => x.name === 'arc--peek') && !(s.t.fw.framework as unknown as { mcplToolRefreshInFlight: boolean }).mcplToolRefreshInFlight, 'the tool list follows the undoing'); + await roundTrip(s.t); + await nextMacrotask(); + assert.equal(s.toldMoved(), 0, 'never told'); + } finally { await s.done(); } +}); + +test('where no tool list has published a tool, presence decides where it is called', () => { + const { c, read } = tappedController(); + assert.match(c.dynamicCallRefusal('peek', 'arc--peek') ?? '', /peek is a dynamic tool of arc/); + read({ kind: 'result', method: 'tools/list', params: {}, result: { tools: withPresence('peek', 'static') }, awaited: true }); + assert.equal(c.dynamicCallRefusal('peek', 'arc--peek'), null); +}); From 3e932319379525aed814a620a37ac8ca31ebafaf Mon Sep 17 00:00:00 2001 From: Octavia-2412 Date: Sun, 25 Oct 2026 22:50:26 -0400 Subject: [PATCH 16/46] docs(changelog): the toolsets host's wiring changes and fixes since 0.27.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0.27.0 folded the fragment that described the RFC-014 host side, so these commits get fragments of their own, beside the hint-state ones, saying what is different since that release. One released promise moves, so it's a .changed entry: a dynamic tool called by its prefixed name was always refused, and now it's taken while a move waits for the tool list (RFC-014 §4.2). The rest bring the code to its text and to RFC-014, so they're .fixed. Co-Authored-By: Claude Opus 5.5 --- changelog.d/mcpl-toolsets-wiring.changed.md | 1 + changelog.d/mcpl-toolsets-wiring.fixed.md | 6 ++++++ 2 files changed, 7 insertions(+) create mode 100644 changelog.d/mcpl-toolsets-wiring.changed.md create mode 100644 changelog.d/mcpl-toolsets-wiring.fixed.md diff --git a/changelog.d/mcpl-toolsets-wiring.changed.md b/changelog.d/mcpl-toolsets-wiring.changed.md new file mode 100644 index 000000000..ac9f281d9 --- /dev/null +++ b/changelog.d/mcpl-toolsets-wiring.changed.md @@ -0,0 +1 @@ +- MCPL RFC-014 tool presence (`toolHints: true`): until a move between static and dynamic reaches the model's tool list, the tool is called at either place, by its prefixed name while the tool list still offers it and through `toolsets--act` where the server's latest `tools/list` put it (RFC-014 §4.2). A dynamic tool called by its prefixed name is therefore no longer always refused. `toolsets--look` naming a tool shows the definition `toolsets--act` would check it against wherever `toolsets--act` would take it. diff --git a/changelog.d/mcpl-toolsets-wiring.fixed.md b/changelog.d/mcpl-toolsets-wiring.fixed.md new file mode 100644 index 000000000..f6932bbe1 --- /dev/null +++ b/changelog.d/mcpl-toolsets-wiring.fixed.md @@ -0,0 +1,6 @@ +- MCPL RFC-014 tool presence (`toolHints: true`), fixes to how the toolsets host is wired into the framework: + - A result's tool change goes with it only where the result reaches an agent: the model's own call, and the operator's puppet, whose call and result are stored in the agent's window. A script's, a module's or a host's own call leaves it to a notice. A dynamic tool's change that wouldn't fit whole within its result's inline cap, or whose call fails after its result was read, goes as a notice too. + - An open or close whose outcome is unknown is reported as unconfirmed rather than as not done, and names `toolsets--look`, which says when it couldn't ask the server. A selector that is present but isn't a name is refused, and a refusal for a name the server doesn't have says what it has. + - Removing a server, or restarting it under another id or with `toolHints` off, leaves no toolset state behind. A stop or a disconnect while a `toolHints` server is still connecting ends the connect. + - The capability mask no longer reports `toolHints`, `toolsets` and `surface` as masked: `toolHints: false` turns hints off. Operator tool-name patterns naming a dynamic tool count as matched. + - Server-chosen values in the toolset host's log lines, and in the trace of a `toolsets--act` that can't be resolved, are redacted with the servers' configuration. A request kept for a late answer no longer holds a call's arguments. From b5ceb972078e1df89dc9a6b0e2721c54f6aaf2e4 Mon Sep 17 00:00:00 2001 From: Prudence-2217 Date: Sun, 25 Oct 2026 23:23:38 -0400 Subject: [PATCH 17/46] fix(mcpl): a move waits for the next refresh, and one way each to take a change and to say what a change shows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From a review of the whole change against what 0.27.0's feature needs: - A move is no reason to rebuild the model's tool list early. The host's own re-read (connect, recovery) no longer asks the framework for a rebuild when it moved a tool, and a move undone before a rebuild published it no longer asks for another: every move reaches the tool list at the next refresh, as a hinted move already did, and the agent is told where the tool went once that refresh publishes it. Until then the tool is called at either place (the previous commit's rule), so an earlier rebuild would buy only an earlier tool list, at the price of a prompt-cache rewrite for each move; RFC-014 §4.2 points to a natural boundary. ToolsetHostDeps.refreshTools and ToolPresence.movesOwed go. - One public takeChange, used inside the controller too; the private take and claim, which were the same thing under other names, go. - render and markShown share one predicate for which changes show a key's tools in a block, so what counts as shown can't drift from what was rendered. - A tools/list read whose pages straddled a change says so in a field of its own (HintRead's `straddled`), not in its params. - sendToolsList's read hook gathers pages with a loop rather than spreading each page into one call's arguments, which a very large page could make throw. The hook's guard stays as containment: nothing known throws there now, and a throw would leave the request unsettled. - toolsets--act's description drops "(or was due as one and none could be written)", host mechanics the model can't act on. Tests: the two hint tests that waited for the tool list to follow a recovery's move without a further list change now check that it waits, then follows at the next refresh (a tools/list_changed). The window tests that held the rebuild the dropped dependency started are the next commit's to change. Co-Authored-By: Claude Opus 5.5 --- README.md | 6 +- .../mcpl-toolsets-hint-state.changed.md | 2 +- src/framework.ts | 1 - src/mcpl/server-connection.ts | 14 ++--- src/mcpl/tool-presence.ts | 15 ++--- src/mcpl/toolset-host.ts | 55 +++++++------------ src/mcpl/toolsets-module.ts | 2 +- test/mcpl-tool-hints.test.ts | 23 +++++--- 8 files changed, 51 insertions(+), 67 deletions(-) diff --git a/README.md b/README.md index 6db5b8f8e..2e7d16c26 100644 --- a/README.md +++ b/README.md @@ -465,9 +465,9 @@ framework registers a `toolsets` module with four constant tools: as their message is read, in `rev` order, whatever becomes of the message (a refused push, a result that came after its timeout). A move between static and dynamic takes effect when the server's `tools/list` states it, and the tool -list follows at its next rebuild, which the host asks for at once when its own -re-read (after a reconnect or a lost `rev`) made the move. Until then the tool -is called at either place, where the tool list shows it or where the server's +list follows at its next rebuild, whichever refresh brings it (a move is no +reason to rebuild the tool list early). Until then the tool is called at either +place, where the tool list shows it or where the server's latest list put it (RFC-014 §4.2: it keeps working where it was), and the agent is told where it went once that rebuild has published the list. A skipped rev re-reads `tools/list` and then `toolsets/list`, which the host diff --git a/changelog.d/mcpl-toolsets-hint-state.changed.md b/changelog.d/mcpl-toolsets-hint-state.changed.md index 069c19e0c..fb8231b27 100644 --- a/changelog.d/mcpl-toolsets-hint-state.changed.md +++ b/changelog.d/mcpl-toolsets-hint-state.changed.md @@ -1,4 +1,4 @@ - MCPL RFC-014 tool presence (`toolHints: true`): - - A move between static and dynamic takes effect when the server's `tools/list` states it, not when a hint announces it, and the agent is told where the tool went once a rebuild of the model's tool list has published it. When the host's own re-read (after a reconnect or a lost `rev`) moves a tool, it rebuilds the tool list at once. + - A move between static and dynamic takes effect when the server's `tools/list` states it, not when a hint announces it, and the agent is told where the tool went once the next rebuild of the model's tool list has published it. A move is no reason to rebuild the tool list early, whether a hint announced it or the host's own re-read found it. - Each `ended` id is told once while the host runs: told ids are kept in memory, an id counts as told only once a notice or a result carrying it reached the agent, and it is forgotten once the server stops reporting it. Rare interleavings, such as a `toolsets--look` overlapping another read of the same server, can tell one twice. - `toolsets--look` naming a tool shows its signature, description and input schema, as before; the README no longer calls that its whole definition. What look's own re-read of the server changed is in its answer when it restates everything, and otherwise told as a notice. diff --git a/src/framework.ts b/src/framework.ts index e38f45c35..848283340 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -27651,7 +27651,6 @@ export class AgentFramework { return (this.toolsetHub = new ToolsetHub({ notify: (serverId, text, opts) => this.recordToolsetNotice(serverId, text, opts), canNotify: (serverId) => CapabilityGrant.of(this.mcplServerRegistry?.getServer(serverId)).has('pushEvents'), - refreshTools: (serverId) => this.handleToolsListChanged(serverId), log: (line) => console.error(`[mcpl] ${line}`), listedName, published: (serverId, tool) => { diff --git a/src/mcpl/server-connection.ts b/src/mcpl/server-connection.ts index 4bc12a39d..80ef58efe 100644 --- a/src/mcpl/server-connection.ts +++ b/src/mcpl/server-connection.ts @@ -242,11 +242,11 @@ export function toolsetDeclarationsOf(advertised: unknown): Record; result: unknown; awaited: boolean } + | { kind: 'result'; method: string; params: Record; result: unknown; awaited: boolean; straddled?: boolean } /** A push/event (carrier 2), as read, before the plane gate or the grant * admit or refuse it. */ | { kind: 'push'; params: unknown } @@ -1728,8 +1728,8 @@ export class McplServerConnection extends EventEmitter { * empty inventory published as complete. The read tap (RFC-014) is handed * the complete inventory once, with the last page's `_meta`, as the last * page's response is read, before anything read after it; a list that - * fails or times out hands it nothing. Its params say `straddled` when its - * pages carry different `mcpl/rev`s. + * fails or times out hands it nothing. It's `straddled` when its pages + * carry different `mcpl/rev`s. */ async sendToolsList(): Promise<{ tools: McpToolDefinition[] }> { const tools: McpToolDefinition[] = []; @@ -1741,12 +1741,12 @@ export class McplServerConnection extends EventEmitter { const onRead = (result: unknown) => { if (!result || typeof result !== 'object' || !Array.isArray((result as { tools?: unknown }).tools)) return; const r = result as { tools: McpToolDefinition[]; nextCursor?: unknown; _meta?: unknown }; - read.push(...r.tools); + for (const t of r.tools) read.push(t); const rev = (r._meta as Record | undefined)?.['mcpl/rev']; if (typeof rev === 'number') revs.add(rev); if (r.nextCursor !== undefined && r.nextCursor !== null) return; this.tap({ - kind: 'result', method: 'tools/list', params: revs.size > 1 ? { straddled: true } : {}, + kind: 'result', method: 'tools/list', params: {}, ...(revs.size > 1 ? { straddled: true } : {}), result: { tools: read, ...(r._meta !== undefined ? { _meta: r._meta } : {}) }, awaited: true, }); }; diff --git a/src/mcpl/tool-presence.ts b/src/mcpl/tool-presence.ts index 25294a821..0c901a388 100644 --- a/src/mcpl/tool-presence.ts +++ b/src/mcpl/tool-presence.ts @@ -79,6 +79,8 @@ export interface ChangeSet { export const emptyChange = (): ChangeSet => ({ changes: [], suggest: [], notes: [] }); export const isEmptyChange = (c: ChangeSet): boolean => !c.changes.length && !c.suggest.length && !c.notes.length && !c.ended?.length; +/** Whether a change to one key shows that key's tools in a block: what render prints and markShown counts as shown. */ +const showsBlock = (c: Change): boolean => !c.closed && (c.opened || c.appeared.length > 0 || c.redefined.length > 0 || c.gone.length > 0); const isObj = (v: unknown): v is Record => typeof v === 'object' && v !== null && !Array.isArray(v); const arr = (v: unknown): unknown[] => (Array.isArray(v) ? v : []); @@ -546,10 +548,7 @@ export class ToolPresence { /** The definitions a change set's tool blocks describe have reached the model. */ markShown(cs: ChangeSet): void { - for (const c of cs.changes) { - if (c.closed || !(c.opened || c.appeared.length > 0 || c.redefined.length > 0 || c.gone.length > 0)) continue; - for (const t of c.now) this.shown.add(toolDigest(t)); - } + for (const c of cs.changes) if (showsBlock(c)) for (const t of c.now) this.shown.add(toolDigest(t)); } /** A set's ended notes whose ids are still untold, each once. */ @@ -570,11 +569,6 @@ export class ToolPresence { // ── boundaries ── - /** Whether a move a tools/list made is still to be shown by a rebuild of the model's tool list. */ - get movesOwed(): boolean { - return this.pendingMoves.size > 0; - } - /** The static↔dynamic moves the lists made since the last rebuild, still to be shown. */ moves(): Array<{ name: string; to: Presence }> { return [...this.pendingMoves].map(([name, to]) => ({ name, to })); @@ -635,8 +629,7 @@ export class ToolPresence { continue; } const title = (c.opened || c.retitled) && !bare ? c.title : undefined; - const setChanged = c.opened || c.appeared.length > 0 || c.redefined.length > 0 || c.gone.length > 0; - if (setChanged) lines.push(...this.toolBlock(c.key, c.now, { headed: !bare, title, seen, describeAll: opts.describeAll })); + if (showsBlock(c)) lines.push(...this.toolBlock(c.key, c.now, { headed: !bare, title, seen, describeAll: opts.describeAll })); else if (title) lines.push(`${this.label(c.key)} ${title}`); if (c.gone.length) lines.push(`now unavailable: ${c.gone.join(', ')}`); if (c.opened && !bare) { diff --git a/src/mcpl/toolset-host.ts b/src/mcpl/toolset-host.ts index c2809fb2c..d652341f2 100644 --- a/src/mcpl/toolset-host.ts +++ b/src/mcpl/toolset-host.ts @@ -64,8 +64,6 @@ export interface ToolsetHostDeps { /** Whether the server's changes may be put into the conversation unasked * (its `pushEvents` grant): notices and reminders. */ canNotify(serverId: string): boolean; - /** Rebuild the model's tool list from the server's (its tools/list_changed path). */ - refreshTools(serverId: string): void; /** The operator's log. */ log(line: string): void; /** How a static tool is named in the tool list (`prefix--tool`). */ @@ -185,7 +183,7 @@ export class ToolsetController { if (connection !== this.connection) return; this.sync(epoch, connection); if (read.kind === 'result') { - this.readResult(read.method, read.params, read.result as Record, read.awaited); + this.readResult(read.method, read.params, read.result as Record, read.awaited, read.straddled === true); return; } if (!this.presence.active || !isObj(read.params)) return; @@ -220,13 +218,13 @@ export class ToolsetController { this.tellUnlessClaimed(p, cs, 'push/event'); } - private readResult(method: string, params: Record, res: Record, awaited: boolean): void { + private readResult(method: string, params: Record, res: Record, awaited: boolean, straddled: boolean): void { let cs: ChangeSet; switch (method) { case 'tools/list': { if (!Array.isArray(res.tools)) return; cs = this.readToolList(res.tools as HintedTool[], res); - if (params.straddled === true && !this.recovering) { + if (straddled && !this.recovering) { this.log('tools/list pages carried different revs, so its earlier pages are older than changes read between them: recovering'); this.scheduleRecovery(); } @@ -414,13 +412,23 @@ export class ToolsetController { * are told first, as notices, so no later change is shown before them. */ takeChange(carrier: unknown): ChangeSet { - return this.take(carrier); + if (!isObj(carrier)) return emptyChange(); + if (this.unclaimed.has(carrier)) { + let before: object | undefined; + for (const res of this.unclaimed.keys()) { + if (res === carrier) break; + before = res; + } + if (before) this.tellUnclaimed(before); + } + this.unclaimed.delete(carrier); + return this.readChanges.get(carrier) ?? emptyChange(); } /** takeChange's change rendered, for a caller that shows it with the carrier * at once, so it counts as told; '' for nothing. */ takeChangeText(carrier: unknown): string { - const cs = this.take(carrier); + const cs = this.takeChange(carrier); if (isEmptyChange(cs)) return ''; const text = this.presence.render(cs); this.told(cs); @@ -442,25 +450,6 @@ export class ToolsetController { return this.readChanges.get(carrier) ?? emptyChange(); } - /** A carrier's change, taken to be told some other way (a notice this controller sends). */ - private claim(carrier: unknown): ChangeSet { - return this.take(carrier); - } - - private take(carrier: unknown): ChangeSet { - if (!isObj(carrier)) return emptyChange(); - if (this.unclaimed.has(carrier)) { - let before: object | undefined; - for (const res of this.unclaimed.keys()) { - if (res === carrier) break; - before = res; - } - if (before) this.tellUnclaimed(before); - } - this.unclaimed.delete(carrier); - return this.readChanges.get(carrier) ?? emptyChange(); - } - // ── recovery (RFC §6.1) ── private scheduleRecovery(): void { @@ -503,15 +492,10 @@ export class ToolsetController { } } - /** - * Whether the list was read; the tap applied it and told its change as it - * was read. When it moved a tool, the model's tool list is rebuilt now - * rather than at the next unrelated refresh. - */ + /** Whether the list was read; the tap applied it and told its change as it was read. */ private async ownToolList(connection: McplServerConnection): Promise { try { await connection.sendToolsList(); - if (this.presence.movesOwed) this.deps.refreshTools(this.serverId); return true; } catch (error) { this.log(`tools/list failed: ${this.failure(error)}`); @@ -522,7 +506,7 @@ export class ToolsetController { private async ownToolsetList(connection: McplServerConnection, timeoutMs: number, why: string): Promise { try { const r = await connection.sendToolsetsList(timeoutMs); - return this.claim(r); + return this.takeChange(r); } catch (error) { this.log(`toolsets/list (${why}) failed: ${this.failure(error)}`); return emptyChange(); @@ -563,7 +547,7 @@ export class ToolsetController { try { const r = await connection.sendToolsetsList(REFRESH_TIMEOUT_MS); if (!isObj(r)) return null; - return opts.shown ? this.claim(r) : this.changeOf(r); + return opts.shown ? this.takeChange(r) : this.changeOf(r); } catch (error) { this.log(`toolsets/list (look) failed: ${this.failure(error)}`); return null; @@ -638,10 +622,9 @@ export class ToolsetController { this.deliver(this.presence.diff(before, this.presence.view())); } - /** At a rebuild of the model's tool list (RFC §4.2), tell where the tools it moved went: `moves`, those still pending, or every one pending. A move a list has undone since the rebuild listed the server asks for another rebuild, so the tool list follows. */ + /** At a rebuild of the model's tool list (RFC §4.2), tell where the tools it moved went: `moves`, those still pending, or every one pending. A move a list has undone since the rebuild listed the server isn't told; the next rebuild publishes the undoing. */ applyMoves(moves?: Array<{ name: string; to: Presence }>): void { const moved = this.presence.applyMoves(moves); - if (moves?.some(({ name, to }) => this.presence.presenceOf(name) !== to)) this.deps.refreshTools(this.serverId); if (!moved.length) return; const cs = emptyChange(); cs.notes.push(...moved.map(({ name, to }) => to === 'dynamic' diff --git a/src/mcpl/toolsets-module.ts b/src/mcpl/toolsets-module.ts index 137ed0085..acca848d8 100644 --- a/src/mcpl/toolsets-module.ts +++ b/src/mcpl/toolsets-module.ts @@ -56,7 +56,7 @@ const META_TOOLS: ToolDefinition[] = [ description: 'Call a tool that was announced in the conversation (a tool of an open toolset, or another dynamic tool); they are not in your tool list. ' + 'args are the arguments its signature shows; instance picks the open copy when several are open; server is needed only when two servers have the tool. ' + - 'Returns the server\'s result, then any change it made in which tools are available, unless that change already went out as a notice (or was due as one and none could be written). toolsets--look shows what is available now.', + 'Returns the server\'s result, then any change it made in which tools are available, unless that change already went out as a notice. toolsets--look shows what is available now.', inputSchema: { type: 'object', properties: { diff --git a/test/mcpl-tool-hints.test.ts b/test/mcpl-tool-hints.test.ts index 17ea8c252..6896648f6 100644 --- a/test/mcpl-tool-hints.test.ts +++ b/test/mcpl-tool-hints.test.ts @@ -891,7 +891,7 @@ test('a hint naming a tool tools/list hasn\'t listed is recovered from tools/lis } finally { await t.done(); } }); -test('a lost move is repaired by the recovery\'s tools/list, and the model\'s tool list follows it without a further list change', async () => { +test('a lost move is repaired by the recovery\'s tools/list, and the model\'s tool list follows it at the next refresh', async () => { const tools: Json[] = TOOLS.map((x) => ({ ...x })); const t = await setup(() => false, {}, ADVERT, tools); const listed = () => t.fw.framework.getAllTools().map((x) => x.name); @@ -912,8 +912,12 @@ test('a lost move is repaired by the recovery\'s tools/list, and the model\'s to t.state.next(); const lists = t.methods().filter((m) => m === 'toolsets/list').length; t.server.peer.notify('tools/hints', t.state.hint([])); - await waitFor(() => !listed().includes('arc--peek'), 'a skipped rev recovered, and the tool list rebuilt from it without peek'); - assert.doesNotMatch(await actOnPeek(), /is not a dynamic tool/); + await waitFor(() => t.methods().filter((m) => m === 'toolsets/list').length > lists, 'a skipped rev recovered'); + await roundTrip(t); + assert.doesNotMatch(await actOnPeek(), /is not a dynamic tool/, 'act takes it at once, where the latest list put it'); + assert.ok(listed().includes('arc--peek'), 'the tool list waits for the next refresh'); + t.server.peer.notify('notifications/tools/list_changed', {}); + await waitFor(() => !listed().includes('arc--peek'), 'the next refresh moves peek out of the tool list'); assert.equal(told('peek is in your tool list now'), 1, 'the first move was applied once'); assert.equal(told('peek left your tool list'), 1, 'and the move back told once'); assert.ok(notices(t.fw).some((x) => x.includes('peek() — Peek around.')), 'and announced, as the list made it visible'); @@ -936,7 +940,7 @@ test('an entry that moves a tool to dynamic keeps the definition it carries', as test('reminders restate only servers whose changes may be told unasked', () => { const told = (canNotify: boolean) => { - const deps = { notify: () => true, canNotify: () => canNotify, refreshTools: () => {}, log: () => {}, listedName: (_s: string, tool: string) => `arc--${tool}` }; + const deps = { notify: () => true, canNotify: () => canNotify, log: () => {}, listedName: (_s: string, tool: string) => `arc--${tool}` }; const hub = new ToolsetHub(deps); const c = hub.controllerFor({ id: 'arc', toolHints: true } as McplServerConfig); c.presence.beginConnection({ toolHints: true, toolsets: { build: { title: 'Building' } } }, true); @@ -1051,7 +1055,7 @@ function tappedController(writes: () => boolean = () => true, tools: Json[] = TO const told: string[] = []; const hub = new ToolsetHub({ notify: (_s: string, text: string) => { if (!writes()) return false; told.push(text); return true; }, - canNotify: () => true, refreshTools: () => {}, log: () => {}, listedName: (_s: string, tool: string) => `arc--${tool}`, + canNotify: () => true, log: () => {}, listedName: (_s: string, tool: string) => `arc--${tool}`, }); const c = hub.controllerFor({ id: 'arc', toolHints: true } as McplServerConfig); let tap: (read: unknown, epoch: number) => void = () => {}; @@ -1424,15 +1428,20 @@ test('a ToolsetsModule built as 0.27.0 built it, ToolsetsModule(hub), takes the assert.equal(unshown.told.length, 1, 'a look whose result reaches no agent leaves it to a notice'); }); -test('a tool a recovery\'s list puts in a toolset leaves the model\'s tool list without a further list change', async () => { +test('a tool a recovery\'s list puts in a toolset leaves the model\'s tool list at the next refresh, and is told so', async () => { const tools: Json[] = TOOLS.map((x) => ({ ...x })); const t = await setup(() => false, {}, ADVERT, tools); const listed = () => t.fw.framework.getAllTools().map((x) => x.name); try { tools.find((x) => x.name === 'status')!._meta = { 'mcpl/toolset': 'build' }; + const lists = t.methods().filter((m) => m === 'toolsets/list').length; t.state.next(); t.server.peer.notify('tools/hints', t.state.hint([])); - await waitFor(() => !listed().includes('arc--status'), 'the tool list rebuilt from the recovery\'s list'); + await waitFor(() => t.methods().filter((m) => m === 'toolsets/list').length > lists, 'a skipped rev recovered'); + await roundTrip(t); + assert.ok(listed().includes('arc--status'), 'the tool list waits for the next refresh'); + t.server.peer.notify('notifications/tools/list_changed', {}); + await waitFor(() => !listed().includes('arc--status'), 'the next refresh moves status out of the tool list'); assert.ok(notices(t.fw).some((x) => x.includes('status left your tool list'))); } finally { await t.done(); } }); From b8614aa022525283e35ffd6c43e8bc617fe55034 Mon Sep 17 00:00:00 2001 From: Octavia-2412 Date: Sun, 25 Oct 2026 23:20:14 -0400 Subject: [PATCH 18/46] fix(mcpl): ToolsetsModule takes its taker, and a server without toolHints reads a structured-only result as 0.27.0 did - ToolsetsModule's takerFor is required. The package exports only `.`, whose index exports ToolsetsModule but no ToolsetHub, and nothing it exports hands one out, so no caller outside the framework can build the module. The default it replaces took every call's result to reach the agent, which is 0.27.0's told-once defect, kept for whoever builds the module next. The tests that build the module pass a taker, and the test of the default goes. - executeMcplToolCall reads a structured-only result as that content's JSON only when it has a tool change to append (taken), not whenever the result reaches an agent (taker). The JSON is there so a change doesn't stand alone in place of the content; without a change, the result reads as 0.27.0 read it, for a server without toolHints too. Test: a server without toolHints whose tool answers with structured content alone: the puppet stores the result as 0.27.0 stored it (an empty content, read at 0a4a2c69). It fails when the JSON view turns on the taker again. Co-Authored-By: Claude Opus 5.5 --- src/framework.ts | 2 +- src/mcpl/toolsets-module.ts | 5 ++--- test/mcpl-tool-hints.test.ts | 26 +++----------------------- test/mcpl-toolsets-wiring.test.ts | 20 ++++++++++++++++++++ 4 files changed, 26 insertions(+), 27 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index 848283340..9d7eae55a 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -22451,7 +22451,7 @@ export class AgentFramework { await this.autoFetchReferences(result.content, serverId, 'register') .catch((e) => console.error('[rfc005] reference registration error:', (e as Error).message)); const blocks = Array.isArray(direct.data) ? direct.data : []; - const read: ToolResult = taker && blocks.length === 0 && Object.prototype.hasOwnProperty.call(direct, 'structured') + const read: ToolResult = taken && blocks.length === 0 && Object.prototype.hasOwnProperty.call(direct, 'structured') ? { ...direct, data: JSON.stringify(direct.structured) ?? 'null' } : Array.isArray(direct.data) ? { ...direct, dataIsContent: true } : direct; return withChange(read); diff --git a/src/mcpl/toolsets-module.ts b/src/mcpl/toolsets-module.ts index acca848d8..dc3026de6 100644 --- a/src/mcpl/toolsets-module.ts +++ b/src/mcpl/toolsets-module.ts @@ -263,11 +263,10 @@ export class ToolsetsModule implements Module { /** `takerFor`: who shows a call's result to an agent, taking the tool * change rendered into it; null when the result may reach none, and its - * change is left to a notice (RFC-014 §6.1: told once). Left out, every - * call's result is taken to reach the agent, as in 0.27.0. */ + * change is left to a notice (RFC-014 §6.1: told once). */ constructor( private readonly hub: ToolsetHub, - private readonly takerFor: (call: ToolCall) => ((taken: TakenChange) => void) | null = () => ({ toolsets, cs }) => toolsets.told(cs), + private readonly takerFor: (call: ToolCall) => ((taken: TakenChange) => void) | null, ) {} async start(_ctx: ModuleContext): Promise {} diff --git a/test/mcpl-tool-hints.test.ts b/test/mcpl-tool-hints.test.ts index 6896648f6..ffc970a72 100644 --- a/test/mcpl-tool-hints.test.ts +++ b/test/mcpl-tool-hints.test.ts @@ -1403,31 +1403,11 @@ test('close with nothing named, after a group the host opened was declared, says const { hub, c } = tappedController(); c.setGroupOpen('cave', true); c.manifestChanged({ toolHints: true, toolsets: { cave: { title: 'Cave' } } }); - const result = await new ToolsetsModule(hub).handleToolCall({ id: 'c1', name: 'close', input: { server: 'arc' } } as never); + const result = await new ToolsetsModule(hub, () => null).handleToolCall({ id: 'c1', name: 'close', input: { server: 'arc' } } as never); assert.ok(result.isError); assert.match(String(result.error), /nothing is open on arc/); }); -test('a ToolsetsModule built as 0.27.0 built it, ToolsetsModule(hub), takes the change its look shows as told', async () => { - const statement = { open: [], _meta: { 'mcpl/hints': { rev: 1, tools: [{ name: 'peek', visible: true }] } } }; - const look = async (module: (hub: ToolsetHub) => ToolsetsModule) => { - const { hub, told } = tappedController(() => true, TOOLS, { - toolsetsList: (r) => { - r({ kind: 'result', method: 'toolsets/list', params: {}, result: statement, awaited: true }); - return statement; - }, - }); - const looked = resultText(await module(hub).handleToolCall({ id: 'l1', name: 'look', input: {} } as never)); - await nextMacrotask(); - return { looked, told }; - }; - const built = await look((hub) => new ToolsetsModule(hub)); - assert.match(built.looked, /peek\(\)/); - assert.equal(built.told.length, 0, 'not told again as a notice'); - const unshown = await look((hub) => new ToolsetsModule(hub, () => null)); - assert.equal(unshown.told.length, 1, 'a look whose result reaches no agent leaves it to a notice'); -}); - test('a tool a recovery\'s list puts in a toolset leaves the model\'s tool list at the next refresh, and is told so', async () => { const tools: Json[] = TOOLS.map((x) => ({ ...x })); const t = await setup(() => false, {}, ADVERT, tools); @@ -1640,7 +1620,7 @@ test('a tool a server describes malformed (a description that isn\'t a string, a assert.match(text, /peek\(\)/); assert.match(text, /delve\(a\?: .*\) — Delve\./); assert.match(text, /burrow\(rows\?: .*\) — Burrow\./); - const looked = await new ToolsetsModule(hub).handleToolCall({ id: 'l1', name: 'look', input: { server: 'arc', tool: 'delve' } } as never); + const looked = await new ToolsetsModule(hub, () => null).handleToolCall({ id: 'l1', name: 'look', input: { server: 'arc', tool: 'delve' } } as never); assert.ok(!looked.isError, String(looked.error)); }); @@ -1649,7 +1629,7 @@ test('look at a toolset whose options a server nests very deep still answers', a for (let i = 0; i < 20000; i++) deep = { type: 'object', properties: { a: deep } }; const { hub, c } = tappedController(); c.manifestChanged({ toolHints: true, toolsets: { maze: { title: 'Maze', options: deep } } }); - const looked = await new ToolsetsModule(hub).handleToolCall({ id: 'l2', name: 'look', input: { server: 'arc', toolset: 'maze' } } as never); + const looked = await new ToolsetsModule(hub, () => null).handleToolCall({ id: 'l2', name: 'look', input: { server: 'arc', toolset: 'maze' } } as never); assert.ok(!looked.isError, String(looked.error)); }); diff --git a/test/mcpl-toolsets-wiring.test.ts b/test/mcpl-toolsets-wiring.test.ts index 43c14c9ec..c6e5f5920 100644 --- a/test/mcpl-toolsets-wiring.test.ts +++ b/test/mcpl-toolsets-wiring.test.ts @@ -170,6 +170,26 @@ test('the operator\'s puppet call stores its result with the tool change its hin } }); +test('a server without toolHints keeps 0.27.0\'s reading of a structured-only result: the puppet stores it as 0.27.0 did', async () => { + const server = await startScriptedServer({ + advert: { featureSets: ADVERT.featureSets }, + tools: [STATUS], + on: (msg, peer) => { + if (msg.method === 'tools/call') { peer.reply(msg.id, { content: [], structuredContent: { seen: ['a', 'b'] } }); return; } + if (msg.id !== undefined) peer.error(msg.id, -32601, 'Method not found'); + }, + }); + const fw = await makeFramework([{ id: 'arc', toolPrefix: 'arc', url: server.url } as McplServerConfig]); + try { + await waitFor(() => fw.framework.getAllTools().some((t) => t.name === 'arc--status'), 'static tools listed'); + const { toolUseId } = await fw.framework.puppetToolCall('scout', 'arc--status', {}); + assert.equal(storedResult(fw, toolUseId), '""', 'stored as 0.27.0 stored it'); + } finally { + await fw.stop(); + await server.close(); + } +}); + test('the puppet stores a result that is structured content alone as that content\'s JSON, then the tool change', async () => { const { server } = await hintingServer({ content: [], structuredContent: { score: 42 } }); const fw = await connectedTo(server); From 6b676515bbc240593231ab99623bd1ac553d2b80 Mon Sep 17 00:00:00 2001 From: Octavia-2412 Date: Sun, 25 Oct 2026 23:29:23 -0400 Subject: [PATCH 19/46] test(mcpl): the wiring's tests assert facts, and start a rebuild with a list_changed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Following a review of the tests against the requirements, for the tests this branch wrote: - A move no longer asks for a rebuild of its own (the commit before this one), so the tests that hold a rebuild open start it with a tools/list_changed after the recovery's list is read: movingServer, and arcAndSlow, which holds the second server's list. - A move a list undoes while the rebuild waits is never told; the rebuild publishes what it listed, where the tool is callable both by its name and through act, and the next refresh follows the undoing. - Where the tool list hasn't published a tool, the server's latest list decides: a dynamic tool a recovery's list adds is refused by name and taken through act while the rebuild is held. This replaces the controller-level test of the same rule. - Refusals, unconfirmed outcomes, look's couldn't-ask line, the §4.2 refusals and the no-agent log line assert the facts they carry (what the server has, unconfirmed, the connection's account, look named, the name a tool is called by) rather than whole sentences. A request known not sent doesn't name look. - toolHints off: the advertisement has nothing of RFC-014 and equals what a server that doesn't advertise toolHints is offered, rather than 0.26.2's literal object, so a capability later offered to every server keeps it true. - A notice starts no turn: the host's status counts no request owed and no turn running, in place of the scheduler's private queue. - A rebuild that never publishes changes nothing a caller sees, so the test that lets one end waits on the framework's own flag, a sync point, never an assertion, and says why. Co-Authored-By: Claude Opus 5.5 --- test/mcpl-tool-hints.test.ts | 113 +++++++++++++++++++----------- test/mcpl-toolsets-e2e.test.ts | 4 +- test/mcpl-toolsets-wiring.test.ts | 9 ++- 3 files changed, 80 insertions(+), 46 deletions(-) diff --git a/test/mcpl-tool-hints.test.ts b/test/mcpl-tool-hints.test.ts index ffc970a72..8c7495296 100644 --- a/test/mcpl-tool-hints.test.ts +++ b/test/mcpl-tool-hints.test.ts @@ -106,18 +106,12 @@ const push = (peer: ScriptedPeer, eventId: string, text: string, extra: Json = { test('toolHints off: the advertisement, tool list and traffic are as before', async () => { const t = await setup(() => false, { toolHints: undefined }); + const { toolHints: _hints, toolsets: _toolsets, surface: _surface, ...plainAdvert } = ADVERT; + const plain = await setup(() => false, { toolHints: undefined }, plainAdvert); try { - assert.deepEqual(t.server.peer.hostCaps?.mcpl, { - version: '0.5', - eventCoalescing: { pushEvents: true, channelsIncoming: true, deferred: true, channelScopedPush: true, retryWindowMs: 3600000 }, - pushEvents: true, - contextHooks: { beforeInference: true }, - inferenceLifecycle: true, - toolLifecycle: { observe: true, inputs: true }, - inferenceRequest: { streaming: true }, - channels: { register: true, lifecycle: true, publish: true, incoming: true, streaming: true, acknowledge: true, typing: true }, - featureSets: true, - }, 'the capabilities 0.26.2 offers every server'); + const offered = t.server.peer.hostCaps?.mcpl as Json; + assert.ok(!('toolHints' in offered) && !('toolsets' in offered) && !('surface' in offered), 'nothing of RFC-014 offered'); + assert.deepEqual(offered, plain.server.peer.hostCaps?.mcpl, 'the offer a server that doesn\'t advertise toolHints gets'); const arcTools = t.fw.framework.getAllTools().filter((x) => x.name.startsWith('arc--')) .map((x) => ({ name: x.name, description: x.description, inputSchema: x.inputSchema })); assert.deepEqual(arcTools, TOOLS.map((x) => ({ name: `arc--${String(x.name)}`, description: x.description, inputSchema: x.inputSchema })), @@ -135,7 +129,10 @@ test('toolHints off: the advertisement, tool list and traffic are as before', as await t.fw.framework.executeToolCall({ id: 'c1', name: 'arc--play', input: { point: 'D4' } } as never); const call = t.server.received.find((m) => m.method === 'tools/call')!; assert.ok(!('_meta' in (call.params as Json)), 'no _meta on the call'); - } finally { await t.done(); } + } finally { + await t.done(); + await plain.done(); + } }); test('vector 3, 11: a push\'s hints apply on arrival whatever is decided about the push; nothing is restated', async () => { @@ -668,11 +665,13 @@ test('an open or close whose outcome the host can\'t know is reported as unconfi try { const r = await callTool(dropped.fw.framework, 'toolsets--open', { server: 'arc', toolset: 'build' }); assert.ok(r.isError); - assert.match(resultText(r), /^arc didn't confirm opening build: The connection to the "arc" server closed before it answered/); - assert.match(resultText(r), /toolsets--look \{server:"arc"\} asks it what's open, or says it couldn't\.$/); + assert.match(resultText(r), /didn't confirm opening build/, 'unconfirmed'); + assert.match(resultText(r), /closed before it answered/, 'with the connection\'s own account'); + assert.match(resultText(r), /toolsets--look \{server:"arc"\}/, 'naming look'); const again = await callTool(dropped.fw.framework, 'toolsets--open', { server: 'arc', toolset: 'build' }); assert.ok(again.isError); - assert.equal(resultText(again), 'arc did not open build: arc is not connected right now; try again later', 'with the connection gone, an open is known not sent'); + assert.match(resultText(again), /did not open build/, 'with the connection gone, an open is known not sent'); + assert.doesNotMatch(resultText(again), /toolsets--look/, 'so there is nothing to look for'); } finally { await dropped.done(); } const refused = await setup((msg, peer, state) => { @@ -688,25 +687,27 @@ test('an open or close whose outcome the host can\'t know is reported as unconfi await callTool(refused.fw.framework, 'toolsets--open', { server: 'arc', toolset: 'build' }); const r = await callTool(refused.fw.framework, 'toolsets--close', { server: 'arc', toolset: 'build' }); assert.ok(r.isError); - assert.match(resultText(r), /^arc didn't confirm closing build: .*the board is locked\. toolsets--look \{server:"arc"\} asks it what's open, or says it couldn't\.$/); + assert.match(resultText(r), /didn't confirm closing build/, 'unconfirmed'); + assert.match(resultText(r), /the board is locked/, 'with the server\'s own account'); + assert.match(resultText(r), /toolsets--look \{server:"arc"\}/, 'naming look'); } finally { await refused.done(); } }); test('a refusal for a name the server doesn\'t have says what it has, or names look', async () => { const t = await setup(); try { - const refusals: Array<[string, Json, RegExp]> = [ - ['toolsets--look', { server: 'arc', toolset: 'nope' }, /^arc has no toolset "nope"; it has game, build, cave$/], - ['toolsets--close', { server: 'arc', toolset: 'nope' }, /^arc has no toolset "nope"; it has game, build, cave$/], - ['toolsets--open', { server: 'arc', toolset: 'nope' }, /^arc has no toolset "nope"; it has game, build, cave$/], - ['toolsets--look', { server: 'arc', tool: 'nope' }, /^arc has no tool "nope": toolsets--look \{server:"arc"\} shows what's available$/], - ['toolsets--look', { server: 'arc', instance: 'g9' }, /^no open instance "g9" on arc; no instance is open there$/], - ['toolsets--open', { toolset: 'nope' }, /^no server that presents toolsets has a toolset "nope": toolsets--look shows what they have$/], + const refusals: Array<[string, Json, RegExp[]]> = [ + ['toolsets--look', { server: 'arc', toolset: 'nope' }, [/"nope"/, /game, build, cave/]], + ['toolsets--close', { server: 'arc', toolset: 'nope' }, [/"nope"/, /game, build, cave/]], + ['toolsets--open', { server: 'arc', toolset: 'nope' }, [/"nope"/, /game, build, cave/]], + ['toolsets--look', { server: 'arc', tool: 'nope' }, [/"nope"/, /toolsets--look \{server:"arc"\}/]], + ['toolsets--look', { server: 'arc', instance: 'g9' }, [/"g9"/, /no instance is open/]], + ['toolsets--open', { toolset: 'nope' }, [/"nope"/, /toolsets--look/]], ]; - for (const [name, input, refusal] of refusals) { + for (const [name, input, facts] of refusals) { const r = await callTool(t.fw.framework, name, input); assert.ok(r.isError, `${name} ${JSON.stringify(input)} is refused`); - assert.match(resultText(r), refusal); + for (const fact of facts) assert.match(resultText(r), fact, `${name} ${JSON.stringify(input)}`); } } finally { await t.done(); } @@ -715,11 +716,12 @@ test('a refusal for a name the server doesn\'t have says what it has, or names l for (const input of [{}, { toolset: 'game' }]) { const r = await callTool(none.fw.framework, 'toolsets--open', input); assert.ok(r.isError); - assert.match(resultText(r), /^no MCPL server presents toolsets now: MCPL tools are called directly, by name$/); + assert.match(resultText(r), /called directly, by name/); } const named = await callTool(none.fw.framework, 'toolsets--open', { server: 'arc', toolset: 'game' }); assert.ok(named.isError); - assert.equal(resultText(named), 'arc sends no tool hints now: its tools are called directly, by name, not through toolsets--act'); + assert.match(resultText(named), /arc sends no tool hints/); + assert.match(resultText(named), /called directly, by name/); } finally { await none.done(); } }); @@ -740,7 +742,7 @@ test('a toolsets--act call whose outcome the host can\'t know names look, for th const results = (t.fw.membrane.lastStream?.receivedToolResults ?? []).flat() as Array<{ toolUseId: string; content: unknown }>; const text = JSON.stringify(results.find((r) => r.toolUseId === 'toolu_act')?.content ?? null); assert.match(text, /closed before it answered/); - assert.match(text, /toolsets--look \{server:\\"arc\\"\} asks it what's open, or says it couldn't\./); + assert.match(text, /toolsets--look \{server:\\"arc\\"\}/, 'naming look'); } finally { await t.done(); } const inProcess = await setup(dropsOnPeek); @@ -750,7 +752,7 @@ test('a toolsets--act call whose outcome the host can\'t know names look, for th const r = await callTool(inProcess.fw.framework, 'toolsets--act', { server: 'arc', tool: 'peek', args: {} }); assert.ok(r.isError); assert.match(resultText(r), /closed before it answered/); - assert.match(resultText(r), /toolsets--look \{server:"arc"\} asks it what's open, or says it couldn't\.$/); + assert.match(resultText(r), /toolsets--look \{server:"arc"\}/, 'naming look'); } finally { await inProcess.done(); } }); @@ -1644,10 +1646,12 @@ test('look says when it couldn\'t ask a server, and shows what the host last kne listing = false; const text = await look(t.fw); assert.match(text, /^arc\n {2}nothing open/); - assert.match(text, /\narc couldn't be asked just now: this is what the host last knew of it\.$/); + assert.match(text, /arc couldn't be asked/, 'says it couldn\'t ask'); + assert.match(text, /what the host last knew/, 'and what it shows instead'); const refused = await callTool(t.fw.framework, 'toolsets--look', { server: 'arc', instance: 'g1' }); assert.ok(refused.isError); - assert.equal(resultText(refused), 'no open instance "g1" on arc; no instance is open there\narc couldn\'t be asked just now: this is what the host last knew of it.'); + assert.match(resultText(refused), /no open instance "g1"/); + assert.match(resultText(refused), /arc couldn't be asked/, 'a refusal says it couldn\'t ask too'); } finally { await t.done(); } }); @@ -1714,6 +1718,9 @@ async function movingServer(moved: Json[]) { phase = 'move'; t.state.next(); t.server.peer.notify('tools/hints', t.state.hint([])); + await waitFor(() => phase === 'hold', 'the recovery\'s tools/list answered'); + await roundTrip(t); + t.server.peer.notify('notifications/tools/list_changed', {}); await waitFor(() => release !== null, 'the rebuild\'s tools/list asked for'); }; return { t, moveAndHold, release: () => release?.() }; @@ -1730,7 +1737,9 @@ test('RFC-014 §4.2: a tool a list moves to static is called both ways until the release(); await waitFor(() => t.fw.framework.getAllTools().some((x) => x.name === 'arc--peek'), 'the rebuild shows peek'); assert.equal(resultText(await t.fw.framework.executeToolCall({ id: 'late', name: 'arc--peek', input: {} } as never)), 'peek done'); - assert.equal(resultText(await callTool(t.fw.framework, 'toolsets--act', { server: 'arc', tool: 'peek', args: {} })), 'peek is not a dynamic tool: static — called by name, as arc--peek. Call it there.'); + const viaActLate = resultText(await callTool(t.fw.framework, 'toolsets--act', { server: 'arc', tool: 'peek', args: {} })); + assert.match(viaActLate, /peek is not a dynamic tool/, 'act refuses it now'); + assert.match(viaActLate, /arc--peek/, 'naming the name it\'s called by'); } finally { release(); await t.done(); @@ -1762,7 +1771,9 @@ test('RFC-014 §4.2: a static tool the tool list hasn\'t published isn\'t taken await moveAndHold(); const viaAct = await callTool(t.fw.framework, 'toolsets--act', { server: 'arc', tool: 'newt', args: {} }); assert.ok(viaAct.isError); - assert.equal(resultText(viaAct), 'newt is not a dynamic tool: static — called by name, as arc--newt. Call it there.'); + assert.match(resultText(viaAct), /newt is not a dynamic tool/); + assert.match(resultText(viaAct), /arc--newt/, 'naming the name it\'s called by'); + assert.equal(resultText(await t.fw.framework.executeToolCall({ id: 'byname', name: 'arc--newt', input: {} } as never)), 'newt done', 'which takes it'); release(); await waitFor(() => t.fw.framework.getAllTools().some((x) => x.name === 'arc--newt'), 'the rebuild publishes newt'); } finally { @@ -1814,9 +1825,13 @@ async function arcAndSlow() { await waitFor(() => t.fw.framework.getAllTools().some((x) => x.name === 'slow--status'), 'slow listed'); const move = async () => { moved = true; - holdNext = true; + const lists = t.methods().filter((m) => m === 'tools/list').length; t.state.next(); t.server.peer.notify('tools/hints', t.state.hint([])); + await waitFor(() => t.methods().filter((m) => m === 'tools/list').length > lists, 'the recovery\'s tools/list asked for'); + await roundTrip(t); + holdNext = true; + t.server.peer.notify('notifications/tools/list_changed', {}); await waitFor(() => held !== null, 'the rebuild waits on slow'); }; const unmove = () => { moved = false; }; @@ -1848,6 +1863,7 @@ test('RFC-014 §4.2: a rebuild that never publishes leaves its moves to the one await waitFor(() => s.t.fw.framework.getAllTools().some((x) => x.name === 'third--status' || x.name === 'arc--peek'), 'the newer rebuild published'); await waitFor(() => s.toldMoved() === 1, 'told by the rebuild that published'); s.release(); + // A rebuild that never publishes changes nothing a caller sees, so its end has no public sign: this waits on the framework's own flag. await waitFor(() => !(s.t.fw.framework as unknown as { mcplToolRefreshInFlight: boolean }).mcplToolRefreshInFlight, 'the older rebuild ended'); await roundTrip(s.t); await nextMacrotask(); @@ -1858,7 +1874,7 @@ test('RFC-014 §4.2: a rebuild that never publishes leaves its moves to the one } }); -test('RFC-014 §4.2: a move a list undoes while the rebuild waits on another server is never told, and the tool list follows the undoing', async () => { +test('RFC-014 §4.2: a move a list undoes while the rebuild waits on another server is never told, and the tool list follows the undoing at the next refresh', async () => { const s = await arcAndSlow(); try { await s.move(); @@ -1868,16 +1884,31 @@ test('RFC-014 §4.2: a move a list undoes while the rebuild waits on another ser s.t.server.peer.notify('tools/hints', s.t.state.hint([])); await waitFor(() => s.t.methods().filter((m) => m === 'toolsets/list').length > lists, 'the undoing list read'); s.release(); - await waitFor(() => !s.t.fw.framework.getAllTools().some((x) => x.name === 'arc--peek') && !(s.t.fw.framework as unknown as { mcplToolRefreshInFlight: boolean }).mcplToolRefreshInFlight, 'the tool list follows the undoing'); + await waitFor(() => s.t.fw.framework.getAllTools().some((x) => x.name === 'arc--peek'), 'the rebuild publishes what it listed'); + assert.equal(resultText(await s.t.fw.framework.executeToolCall({ id: 'stale', name: 'arc--peek', input: {} } as never)), 'peek done', 'callable where the tool list shows it'); + assert.equal(resultText(await callTool(s.t.fw.framework, 'toolsets--act', { server: 'arc', tool: 'peek', args: {} })), 'peek done', 'and where the server\'s latest list put it'); + s.t.server.peer.notify('notifications/tools/list_changed', {}); + await waitFor(() => !s.t.fw.framework.getAllTools().some((x) => x.name === 'arc--peek'), 'the next refresh follows the undoing'); await roundTrip(s.t); await nextMacrotask(); assert.equal(s.toldMoved(), 0, 'never told'); } finally { await s.done(); } }); -test('where no tool list has published a tool, presence decides where it is called', () => { - const { c, read } = tappedController(); - assert.match(c.dynamicCallRefusal('peek', 'arc--peek') ?? '', /peek is a dynamic tool of arc/); - read({ kind: 'result', method: 'tools/list', params: {}, result: { tools: withPresence('peek', 'static') }, awaited: true }); - assert.equal(c.dynamicCallRefusal('peek', 'arc--peek'), null); +test('RFC-014 §4.2: where the tool list hasn\'t published a tool, the server\'s latest list decides where it is called', async () => { + const newd = { name: 'newd', description: 'A new dynamic tool.', inputSchema: { type: 'object', properties: {} }, _meta: { 'mcpl/presence': 'dynamic', 'mcpl/visible': true } }; + const { t, moveAndHold, release } = await movingServer([...withPresence('peek', 'static'), newd]); + try { + await moveAndHold(); + const byName = await t.fw.framework.executeToolCall({ id: 'byname', name: 'arc--newd', input: {} } as never); + assert.ok(byName.isError, 'refused by name'); + assert.match(String(byName.error), /newd is a dynamic tool of arc/); + assert.equal(resultText(await callTool(t.fw.framework, 'toolsets--act', { server: 'arc', tool: 'newd', args: {} })), 'newd done', 'taken through act'); + release(); + await waitFor(() => t.fw.framework.getAllTools().some((x) => x.name === 'arc--peek'), 'the rebuild publishes'); + } finally { + release(); + await t.done(); + } }); + diff --git a/test/mcpl-toolsets-e2e.test.ts b/test/mcpl-toolsets-e2e.test.ts index c6d5ac753..f387992aa 100644 --- a/test/mcpl-toolsets-e2e.test.ts +++ b/test/mcpl-toolsets-e2e.test.ts @@ -91,14 +91,14 @@ describe('RFC-014 host, end to end against the reference ToolsetServer', () => { assert.match(text, /\[arcade\/game:g1\] Game g1 \(13\)/); assert.match(text, /board of g1: 0 moves/, 'the server\'s own content for the open'); assert.match(text, /play\(point: string\) — Play a point on the board\./); - assert.match(text, /toolsets--close \{server:"arcade", toolset:"game", instance:"g1"\} closes it\./); + assert.match(text, /toolsets--close \{server:"arcade", toolset:"game", instance:"g1"\}/, 'naming how to close it'); assert.equal(fw.framework.getAllTools().map((t) => t.name).join(','), before, 'the API tool list did not change'); await callTool(fw.framework, 'toolsets--open', { toolset: 'game', instance: 'g2' }); // Two open: act without instance fails, naming them; nothing is sent. const ambiguous = await callTool(fw.framework, 'toolsets--act', { tool: 'play', args: { point: 'D4' } }); assert.ok(ambiguous.isError); - assert.match(resultText(ambiguous), /several game instances are open \(g1, g2\)/); + assert.match(resultText(ambiguous), /g1, g2/, 'naming the open instances'); const played = await callTool(fw.framework, 'toolsets--act', { tool: 'play', instance: 'g1', args: { point: 'D4' } }); assert.ok(!played.isError, resultText(played)); assert.deepEqual(calls, ['g1:play:{"point":"D4"}']); diff --git a/test/mcpl-toolsets-wiring.test.ts b/test/mcpl-toolsets-wiring.test.ts index c6e5f5920..64e3b6750 100644 --- a/test/mcpl-toolsets-wiring.test.ts +++ b/test/mcpl-toolsets-wiring.test.ts @@ -308,7 +308,9 @@ test('a tool change the host can\'t tell for want of an agent is logged, as one try { server.peer.notify('tools/hints', showPeek()); await waitFor(() => logged.some((line) => line.includes('arc: a tool change was not told')), 'the drop logged'); - assert.ok(logged.some((line) => /arc: a tool change was not told \(no agent is registered to tell\); toolsets--look shows the state it left/.test(line))); + const line = logged.find((l) => l.includes('arc: a tool change was not told'))!; + assert.match(line, /no agent/, 'says why'); + assert.match(line, /toolsets--look/, 'and names look'); } finally { console.error = error; await fw.stop(); @@ -323,8 +325,9 @@ test('a tool change with no result to ride on is a notice only where pushEvents const requests = withGrant.membrane.calls.length; granted.server.peer.notify('tools/hints', granted.showPeek()); await waitFor(() => notices(withGrant).some((x) => x.includes('peek()')), 'the notice written'); - const scheduler = withGrant.framework as unknown as { pendingRequests: unknown[] }; - assert.equal(scheduler.pendingRequests.length, 0, 'no inference requested'); + const status = withGrant.framework.getHostModeStatus(); + assert.equal(status.gatedRequests, 0, 'no inference requested'); + assert.equal(status.activeTurns, 0, 'no turn running'); assert.equal(withGrant.framework.getAgent('scout')!.state.status, 'idle', 'no turn running'); assert.equal(withGrant.membrane.calls.length, requests, 'no request sent: the notice started no turn'); } finally { From f9173ef23af315538bf18e58adbfeafa3a1c0f3e Mon Sep 17 00:00:00 2001 From: Prudence-2217 Date: Sun, 25 Oct 2026 23:35:48 -0400 Subject: [PATCH 20/46] test(mcpl): the hint-state tests pin each requirement where the agent meets it From the tests weighed against the requirements: - Four tests that repeated a caller-level test go: reminders only for a server granted pushEvents, an ended record not told again after the connection is let go, a move a later list reverses, and taking a change not counting its ended record. - Re-expressed through the scripted server and the agent's window, each with what reaches the agent: - a call's change told as a notice before the puppet stores the call isn't stored again with its result; without pushEvents it's dropped, not stored; - an ended record two statements report before either is told is told once, and one that expires while its note waits is told once; - an instance reopened under an ended instance's name is told closed when it goes; - a recovery's tools/list change, and a change a filtered look hands back, are told before a hint read after them in the same chunk; - a notice describes a definition it shows in two instances once; - a hint restating the presence a recovery's list moved a tool to leaves the move to be told at the next refresh; - a definition given in a hint after a separate hinted move is used once the tool list makes the tool dynamic; - a tool listed twice is known, and checked by act, by its first listing; - a first sentence is cut after a whole character; - a group the host opened, which the manifest then declares and drops, is no longer open, so act is refused; - a tool described with a non-string description, or a schema nested 20,000 levels as objects or as arrays (sent as text, since JSON.stringify can't write it), is announced, and look answers. - Kept in process, each saying why in its name: taking a change first telling an earlier one (in an agent's window a notice told during a call is stored after the call's result), a definition described again after an unwritten notice (a server's grant doesn't change while it's connected), a push nobody takes (needs a stopping host), a push keeping its change for its own path (needs that path to run after the next macrotask), and look at a toolset with deep options (a manifest that deep isn't taken by the framework's manifest read). - The in-process harness keeps only what those tests use. By run (Linux, Node 24.22.1): the three toolset files pass 125 of 125; typecheck clean. Co-Authored-By: Claude Opus 5.5 --- test/mcpl-tool-hints.test.ts | 539 +++++++++++++++++++---------------- 1 file changed, 297 insertions(+), 242 deletions(-) diff --git a/test/mcpl-tool-hints.test.ts b/test/mcpl-tool-hints.test.ts index 8c7495296..9ca24e4e5 100644 --- a/test/mcpl-tool-hints.test.ts +++ b/test/mcpl-tool-hints.test.ts @@ -940,39 +940,6 @@ test('an entry that moves a tool to dynamic keeps the definition it carries', as } finally { await t.done(); } }); -test('reminders restate only servers whose changes may be told unasked', () => { - const told = (canNotify: boolean) => { - const deps = { notify: () => true, canNotify: () => canNotify, log: () => {}, listedName: (_s: string, tool: string) => `arc--${tool}` }; - const hub = new ToolsetHub(deps); - const c = hub.controllerFor({ id: 'arc', toolHints: true } as McplServerConfig); - c.presence.beginConnection({ toolHints: true, toolsets: { build: { title: 'Building' } } }, true); - c.presence.setTools([{ name: 'place', description: 'Place a block.', inputSchema: { type: 'object' }, _meta: { 'mcpl/toolset': 'build' } }]); - c.presence.noteOpened('build', undefined, 'Workshop'); - hub.observeCompile('scout', [{ role: 'user', content: [{ type: 'text', text: '[arc/build] Workshop tools now' }] }]); - hub.observeCompile('scout', [{ role: 'user', content: [{ type: 'text', text: 'later' }] }]); - return hub.reminderFor('scout'); - }; - assert.match(told(true) ?? '', /\[arc\/build\] Workshop/); - assert.equal(told(false), null); -}); - -test('a first sentence is cut by characters, never inside a surrogate pair', () => { - const cut = firstSentence(`${'a'.repeat(138)}😀😀 and more`); - assert.equal([...cut].length, 140); - assert.ok(cut.endsWith('…')); - assert.doesNotMatch(cut, /[\uD800-\uDBFF](?![\uDC00-\uDFFF])/, 'no lone high surrogate'); -}); - -test('a group the host opened that the manifest then declares, and later drops, isn\'t open', () => { - const p = new ToolPresence('arc', META_TOOL_NAMES); - p.beginConnection({ toolHints: true }, true); - p.setTools([{ name: 'dig', description: 'Dig.', inputSchema: { type: 'object' }, _meta: { 'mcpl/toolset': 'cave' } }]); - p.openGroup('cave'); - p.setManifest({ toolHints: true, toolsets: { cave: { title: 'Cave' } } }); - p.setManifest({ toolHints: true }); - assert.equal(p.isOpen('cave'), false); -}); - test('a server that didn\'t advertise toolHints: its hints are ignored and every tool stays static', async () => { const t = await setup((msg, peer) => { if (msg.method !== 'tools/call') return false; @@ -1053,7 +1020,7 @@ test('an instance the host believed open, missing from the next statement with n } finally { await t.done(); } }); -function tappedController(writes: () => boolean = () => true, tools: Json[] = TOOLS, requests: Record void) => unknown> = {}) { +function tappedController(writes: () => boolean = () => true) { const told: string[] = []; const hub = new ToolsetHub({ notify: (_s: string, text: string) => { if (!writes()) return false; told.push(text); return true; }, @@ -1061,46 +1028,15 @@ function tappedController(writes: () => boolean = () => true, tools: Json[] = TO }); const c = hub.controllerFor({ id: 'arc', toolHints: true } as McplServerConfig); let tap: (read: unknown, epoch: number) => void = () => {}; - const connection = () => ({ - setReadTap: (f: typeof tap | null) => { if (f) tap = f; }, transportEpoch: 0, isConnected: true, toolsetDeclarations: ADVERT, - sendToolsList: async () => requests.toolsList?.(read), - sendToolsetsList: async () => requests.toolsetsList?.(read), - }); - const attached = connection(); - c.attach(attached as never); + c.attach({ setReadTap: (f: typeof tap | null) => { if (f) tap = f; }, transportEpoch: 0, isConnected: true, toolsetDeclarations: ADVERT } as never); const read = (r: unknown) => tap(r, 0); - read({ kind: 'result', method: 'tools/list', params: {}, result: { tools }, awaited: true }); + read({ kind: 'result', method: 'tools/list', params: {}, result: { tools: TOOLS }, awaited: true }); const callResult = (rev: number, tools: Json[]) => ({ content: [{ type: 'text', text: 'ok' }], _meta: { 'mcpl/hints': { rev, tools } } }); const readCall = (result: Json) => read({ kind: 'result', method: 'tools/call', params: { name: 'status' }, result, awaited: true }); - const reattach = () => { - c.detach(attached as never); - c.attach(connection() as never); - read({ kind: 'result', method: 'tools/list', params: {}, result: { tools }, awaited: true }); - }; - return { hub, c, told, read, callResult, readCall, reattach }; + return { hub, c, told, read, callResult, readCall }; } -test('a result\'s change told as a notice before its caller takes it isn\'t shown again with the result', () => { - const { c, told, read, callResult, readCall } = tappedController(); - const result = callResult(1, [{ name: 'peek', visible: true }]); - readCall(result); - read({ kind: 'hints', params: { rev: 2, tools: [{ name: 'peek', visible: false }] } }); - assert.equal(told.length, 2, 'the result\'s change, then the later one'); - assert.match(told[0]!, /peek\(\)/); - assert.match(told[1]!, /now unavailable: peek/); - assert.equal(c.takeChangeText(result), '', 'not shown again with the result'); -}); - -test('a result\'s change drained while no notice can be written is dropped, not shown later with its result', () => { - const { c, told, read, callResult, readCall } = tappedController(() => false); - const result = callResult(1, [{ name: 'peek', visible: true }]); - readCall(result); - read({ kind: 'hints', params: { rev: 2, tools: [] } }); - assert.equal(told.length, 0); - assert.equal(c.takeChangeText(result), ''); -}); - -test('taking a result\'s change first tells, as a notice, the change of a result read before it', () => { +test('taking a result\'s change first tells, as a notice, the change of a result read before it (in process: in an agent\'s window a notice told during a call is stored after the call\'s result)', () => { const { c, told, callResult, readCall } = tappedController(); readCall(callResult(1, [{ name: 'peek', visible: true }])); const later = callResult(2, [{ name: 'peek', visible: false }]); @@ -1111,15 +1047,6 @@ test('taking a result\'s change first tells, as a notice, the change of a result assert.match(shown, /now unavailable: peek/); }); -test('an ended record two statements report before either is told is told once', async () => { - const { told, read } = tappedController(); - const statement = (rev: number) => ({ open: [], ended: [{ id: 'e1', toolset: 'game', instance: 'g1', reason: 'the game is over' }], _meta: { 'mcpl/hints': { rev, tools: [] } } }); - read({ kind: 'result', method: 'toolsets/list', params: {}, result: statement(1), awaited: true }); - read({ kind: 'result', method: 'toolsets/list', params: {}, result: statement(2), awaited: true }); - await nextMacrotask(); - assert.equal(told.join('\n').split('[arc/game:g1] ended: the game is over.').length - 1, 1); -}); - test('a hint read right after a tools/list\'s last page, in the same chunk, isn\'t undone by that list', async () => { let held: Json | null = null; let holding = false; @@ -1350,7 +1277,7 @@ test('look with a tool or toolset, or one that fails, tells its re-read\'s chang } finally { await t.done(); } }); -test('a definition first described in a notice that wasn\'t written is described again', () => { +test('a definition first described in a notice that wasn\'t written is described again (in process: a server\'s grant doesn\'t change while it\'s connected, so a refused notice can\'t be followed by a written one)', () => { let writing = false; const { told, read } = tappedController(() => writing); read({ kind: 'hints', params: { rev: 1, tools: [{ name: 'peek', visible: true }] } }); @@ -1361,46 +1288,13 @@ test('a definition first described in a notice that wasn\'t written is described assert.match(told[0]!, /peek\(\) — Peek around\./); }); -test('a tool listed twice is known by its first listing, which act checks arguments against', () => { - const tools = [...TOOLS.filter((t) => t.name !== 'peek'), - { name: 'peek', description: 'The first listing.', inputSchema: { type: 'object', properties: { far: { type: 'boolean' } } }, _meta: { 'mcpl/presence': 'dynamic', 'mcpl/visible': false } }, - { name: 'peek', description: 'The second listing.', inputSchema: { type: 'object', properties: { far: { type: 'string' } } }, _meta: { 'mcpl/presence': 'dynamic', 'mcpl/visible': false } }]; - const { hub, told, read } = tappedController(() => true, tools); - read({ kind: 'hints', params: { rev: 1, tools: [{ name: 'peek', visible: true }] } }); - assert.match(told.join('\n'), /peek\(far\?: boolean\) — The first listing\./); - const target = resolveAct(hub, { tool: 'peek', args: { far: 'yes' } }); - assert.ok(!target.ok, 'checked against the first listing\'s schema'); -}); - -test('taking a change doesn\'t count its ended record as told: only told() does', async () => { - const { c, told, read } = tappedController(); - const statement = (rev: number) => ({ open: [], ended: [{ id: 'e1', toolset: 'game', instance: 'g1', reason: 'the game is over' }], _meta: { 'mcpl/hints': { rev, tools: [] } } }); - const first = statement(1); - read({ kind: 'result', method: 'toolsets/list', params: {}, result: first, awaited: true }); - c.takeChange(first); - read({ kind: 'result', method: 'toolsets/list', params: {}, result: statement(2), awaited: true }); - await nextMacrotask(); - assert.match(told.join('\n'), /\[arc\/game:g1\] ended: the game is over\./, 'still owed after a take with no told'); -}); - -test('a push\'s change that nothing takes is told as a notice', async () => { +test('a push\'s change that nothing takes is told as a notice (in process: a push refused before the push path reads it needs a stopping host)', async () => { const { told, read } = tappedController(); read({ kind: 'push', params: { featureSet: 'arc.events', eventId: 'p1', payload: { content: [] }, hints: { rev: 1, tools: [{ name: 'peek', visible: true }] } } }); await nextMacrotask(); assert.match(told.join('\n'), /peek\(\)/); }); -test('an ended record told before the server\'s connection was let go isn\'t told again on the next one', async () => { - const { told, read, reattach } = tappedController(); - const statement = (rev: number) => ({ open: [], ended: [{ id: 'e1', toolset: 'game', instance: 'g1', reason: 'the game is over' }], _meta: { 'mcpl/hints': { rev, tools: [] } } }); - read({ kind: 'result', method: 'toolsets/list', params: {}, result: statement(1), awaited: true }); - await nextMacrotask(); - reattach(); - read({ kind: 'result', method: 'toolsets/list', params: {}, result: statement(1), awaited: true }); - await nextMacrotask(); - assert.equal(told.join('\n').split('ended: the game is over.').length - 1, 1); -}); - test('close with nothing named, after a group the host opened was declared, says nothing is open', async () => { const { hub, c } = tappedController(); c.setGroupOpen('cave', true); @@ -1443,69 +1337,6 @@ test('after a reconnect whose tools/list moved a tool, the model\'s tool list ag } finally { await t.done(); } }); -test('a recovery\'s tools/list change is told as it is read, before a hint read after it', async () => { - const statement = { open: [], _meta: { 'mcpl/hints': { rev: 4, tools: [] } } }; - const { told, read } = tappedController(() => true, TOOLS, { - toolsList: (r) => { - const tools = TOOLS.map((x) => (x.name === 'peek' ? { ...x, _meta: { 'mcpl/presence': 'dynamic', 'mcpl/visible': true } } : x)); - r({ kind: 'result', method: 'tools/list', params: {}, result: { tools, _meta: { 'mcpl/rev': 2 } }, awaited: true }); - r({ kind: 'hints', params: { rev: 3, tools: [{ name: 'peek', visible: false }] } }); - return { tools }; - }, - toolsetsList: (r) => { - r({ kind: 'result', method: 'toolsets/list', params: {}, result: statement, awaited: true }); - return statement; - }, - }); - read({ kind: 'hints', params: { rev: 2, tools: [] } }); - await waitFor(() => told.some((x) => x.includes('now unavailable: peek')), 'the recovery ran', 2000); - const shown = told.findIndex((x) => x.includes('peek()')); - const gone = told.findIndex((x) => x.includes('now unavailable: peek')); - assert.ok(shown >= 0 && shown < gone, 'the list\'s change first, then the hint\'s'); -}); - -test('a change look hands back is told before a change read after it', async () => { - const statement = { open: [], _meta: { 'mcpl/hints': { rev: 1, tools: [{ name: 'peek', visible: true }] } } }; - const { c, told, read, callResult, readCall } = tappedController(() => true, TOOLS, { - toolsetsList: (r) => { - r({ kind: 'result', method: 'toolsets/list', params: {}, result: statement, awaited: true }); - return statement; - }, - }); - const cs = await c.refreshOpen({ shown: true }); - readCall(callResult(2, [{ name: 'peek', visible: false }])); - c.tell(cs!); - await nextMacrotask(); - const shown = told.findIndex((x) => x.includes('peek()')); - const gone = told.findIndex((x) => x.includes('now unavailable: peek')); - assert.ok(shown >= 0 && shown < gone, 'look\'s change first'); -}); - -test('an ended record that expires while its notes wait is still told once', async () => { - const { told, read } = tappedController(); - const statement = (rev: number, ended: Json[]) => ({ open: [], ended, _meta: { 'mcpl/hints': { rev, tools: [] } } }); - const e1 = [{ id: 'e1', toolset: 'game', instance: 'g1', reason: 'the game is over' }]; - read({ kind: 'result', method: 'toolsets/list', params: {}, result: statement(1, e1), awaited: true }); - read({ kind: 'result', method: 'toolsets/list', params: {}, result: statement(2, e1), awaited: true }); - read({ kind: 'result', method: 'toolsets/list', params: {}, result: statement(3, []), awaited: true }); - await nextMacrotask(); - assert.equal(told.join('\n').split('ended: the game is over.').length - 1, 1); -}); - -test('an instance reopened under an ended instance\'s name is told closed when it goes', async () => { - const { told, read } = tappedController(); - const statement = (rev: number, open: Json[], ended: Json[]) => ({ open, ended, _meta: { 'mcpl/hints': { rev, tools: [] } } }); - const e1 = [{ id: 'e1', toolset: 'game', instance: 'g1', reason: 'the game is over' }]; - const g1 = [{ toolset: 'game', instance: 'g1', title: 'G1' }]; - read({ kind: 'result', method: 'toolsets/list', params: {}, result: statement(1, g1, []), awaited: true }); - read({ kind: 'result', method: 'toolsets/list', params: {}, result: statement(2, [], e1), awaited: true }); - await nextMacrotask(); - read({ kind: 'result', method: 'toolsets/list', params: {}, result: statement(3, g1, e1), awaited: true }); - read({ kind: 'result', method: 'toolsets/list', params: {}, result: statement(4, [], e1), awaited: true }); - await nextMacrotask(); - assert.match(told.join('\n'), /\[arc\/game:g1\] is closed/); -}); - test('a paged tools/list whose rev only some pages carry isn\'t taken for one that straddled a change', async () => { let paging = false; const t = await setup((msg, peer, state) => { @@ -1525,42 +1356,8 @@ test('a paged tools/list whose rev only some pages carry isn\'t taken for one th } finally { await t.done(); } }); -test('a notice describes a definition it shows twice only once', async () => { - const { told, read } = tappedController(); - const statement = { open: [{ toolset: 'game', instance: 'g1', title: 'G1' }, { toolset: 'game', instance: 'g2', title: 'G2' }], _meta: { 'mcpl/hints': { rev: 1, tools: [] } } }; - read({ kind: 'result', method: 'toolsets/list', params: {}, result: statement, awaited: true }); - await nextMacrotask(); - read({ kind: 'hints', params: { rev: 2, tools: [{ name: 'play', instance: 'g1', visible: true }, { name: 'play', instance: 'g2', visible: true }] } }); - const text = told.join('\n'); - assert.match(text, /\[arc\/game:g1\]/, 'both instances are announced'); - assert.match(text, /\[arc\/game:g2\]/, 'both instances are announced'); - assert.equal(text.split('play(').length - 1, 2, 'the tool in each'); - assert.equal(text.split('Play a point.').length - 1, 1, text); -}); - -const listOf = (tools: Json[]) => ({ kind: 'result', method: 'tools/list', params: {}, result: { tools }, awaited: true }); -const movedPeek = (presence: 'static' | 'dynamic') => TOOLS.map((x) => (x.name === 'peek' ? { ...x, _meta: { ...(x._meta as Json), 'mcpl/presence': presence } } : x)); - -test('a hint restating the presence a tools/list moved a tool to leaves the move to be told at the rebuild', () => { - const { c, told, read } = tappedController(); - read(listOf(TOOLS)); - read(listOf(movedPeek('static'))); - read({ kind: 'hints', params: { rev: 1, tools: [{ name: 'peek', presence: 'static' }] } }); - assert.doesNotMatch(told.join('\n'), /peek is in your tool list now/, 'not before the rebuild'); - c.applyMoves(); - assert.match(told.join('\n'), /peek is in your tool list now/); -}); - -test('a move a later tools/list reverses before the rebuild is never told', () => { - const { c, told, read } = tappedController(); - read(listOf(TOOLS)); - read(listOf(movedPeek('static'))); - read(listOf(movedPeek('dynamic'))); - c.applyMoves(); - assert.doesNotMatch(told.join('\n'), /peek (is in|left) your tool list/); -}); -test('a push whose change no notice could carry keeps it for the push\'s own path to take', async () => { +test('a push whose change no notice could carry keeps it for the push\'s own path to take (in process: it needs a push whose own path runs after the next macrotask)', async () => { let writes = false; const { c, read } = tappedController(() => writes); const params = { featureSet: 'arc.events', eventId: 'p1', payload: { content: [] }, hints: { rev: 1, tools: [{ name: 'peek', visible: true }] } }; @@ -1596,37 +1393,7 @@ test('a recovery whose own tools/list straddled a change starts no second recove } finally { await t.done(); } }); -test('a definition a hint gives after a separate hinted move is kept for when the list makes the tool dynamic', () => { - const { told, read } = tappedController(); - read(listOf(TOOLS)); - read({ kind: 'hints', params: { rev: 1, tools: [{ name: 'status', presence: 'dynamic' }] } }); - read({ kind: 'hints', params: { rev: 2, tools: [{ name: 'status', definition: { description: 'Status, from a hint.', inputSchema: { type: 'object', properties: {} } } }] } }); - read(listOf(TOOLS.map((x) => (x.name === 'status' ? { ...x, _meta: { 'mcpl/presence': 'dynamic', 'mcpl/visible': true } } : x)))); - assert.match(told.join('\n'), /status\(\) — Status, from a hint\./); -}); - -test('a tool a server describes malformed (a description that isn\'t a string, a schema nested very deep) is still announced and looked at', async () => { - let deep: Json = { type: 'string' }; - for (let i = 0; i < 20000; i++) deep = { type: 'object', properties: { a: deep } }; - let rows: Json = { type: 'string' }; - for (let i = 0; i < 20000; i++) rows = { type: 'array', items: rows }; - const tools = [ - ...TOOLS.filter((x) => x.name !== 'peek'), - { name: 'peek', description: 42, inputSchema: { type: 'object', properties: {} }, _meta: { 'mcpl/presence': 'dynamic', 'mcpl/visible': false } }, - { name: 'delve', description: 'Delve.', inputSchema: deep, _meta: { 'mcpl/presence': 'dynamic', 'mcpl/visible': false } }, - { name: 'burrow', description: 'Burrow.', inputSchema: { type: 'object', properties: { rows } }, _meta: { 'mcpl/presence': 'dynamic', 'mcpl/visible': false } }, - ]; - const { hub, told, read } = tappedController(() => true, tools); - read({ kind: 'hints', params: { rev: 1, tools: [{ name: 'peek', visible: true }, { name: 'delve', visible: true }, { name: 'burrow', visible: true }] } }); - const text = told.join('\n'); - assert.match(text, /peek\(\)/); - assert.match(text, /delve\(a\?: .*\) — Delve\./); - assert.match(text, /burrow\(rows\?: .*\) — Burrow\./); - const looked = await new ToolsetsModule(hub, () => null).handleToolCall({ id: 'l1', name: 'look', input: { server: 'arc', tool: 'delve' } } as never); - assert.ok(!looked.isError, String(looked.error)); -}); - -test('look at a toolset whose options a server nests very deep still answers', async () => { +test('look at a toolset whose options a server nests very deep still answers (in process: a manifest nested that deep isn\'t taken by the framework\'s manifest read, so no server reaches it)', async () => { let deep: Json = { type: 'string' }; for (let i = 0; i < 20000; i++) deep = { type: 'object', properties: { a: deep } }; const { hub, c } = tappedController(); @@ -1912,3 +1679,291 @@ test('RFC-014 §4.2: where the tool list hasn\'t published a tool, the server\'s } }); +/** Several of the connection's reads in one synchronous run, as when they arrive in one chunk. */ +const chunk = (t: Awaited>, ...messages: Json[]) => { + const connection = (t.fw.framework as unknown as { mcplServerRegistry: { getServer(id: string): unknown } }).mcplServerRegistry.getServer('arc'); + const transport = (connection as { transport: { emit(event: string, line: string): void } }).transport; + for (const m of messages) transport.emit('line', JSON.stringify({ jsonrpc: '2.0', ...m })); +}; +/** A server that holds the requests of the methods in `holding` until a test answers them. */ +async function holdingServer(config: Partial = {}) { + const holding = new Set(); + const held: Json[] = []; + const t = await setup((msg) => { + if (!holding.has(String(msg.method))) return false; + held.push(msg); + return true; + }, config); + return { t, holding, held }; +} +const storedResult = (t: Awaited>, toolUseId: string): string => JSON.stringify( + t.fw.framework.getAgent('scout')!.getContextManager().getAllMessages() + .flatMap((m) => m.content as unknown as Array>) + .find((b) => b.type === 'tool_result' && b.toolUseId === toolUseId)?.content ?? null); +const statusResult = (id: unknown, hints: Json) => ({ id, result: { content: [{ type: 'text', text: 'status done' }], _meta: { 'mcpl/hints': hints } } }); + +test('a call\'s change told as a notice before the puppet stores the call isn\'t stored again with its result', async () => { + const { t, holding, held } = await holdingServer(); + try { + holding.add('tools/call'); + const call = t.fw.framework.puppetToolCall('scout', 'arc--status', {}); + await waitFor(() => held.length === 1, 'the call reached the server'); + holding.clear(); + const before = notices(t.fw).length; + chunk(t, statusResult(held[0]!.id, t.state.hint([{ name: 'peek', visible: true }])), + { method: 'tools/hints', params: t.state.hint([{ name: 'peek', visible: false }]) }); + const { toolUseId } = await call; + const told = notices(t.fw).slice(before); + const shown = told.findIndex((x) => x.includes('peek()')); + const gone = told.findIndex((x) => x.includes('now unavailable: peek')); + assert.ok(shown >= 0 && shown < gone, 'the call\'s change, then the later one'); + assert.doesNotMatch(storedResult(t, toolUseId), /peek\(\)/, 'not stored again with the result'); + } finally { await t.done(); } +}); + +test('without the pushEvents grant, a call\'s change a later read drains before the puppet stores the call is dropped, not stored with its result', async () => { + const { t, holding, held } = await holdingServer({ disabledCapabilities: ['pushEvents'] }); + try { + holding.add('tools/call'); + const call = t.fw.framework.puppetToolCall('scout', 'arc--status', {}); + await waitFor(() => held.length === 1, 'the call reached the server'); + holding.clear(); + const before = notices(t.fw).length; + chunk(t, statusResult(held[0]!.id, t.state.hint([{ name: 'peek', visible: true }])), + { method: 'tools/hints', params: t.state.hint([]) }); + const { toolUseId } = await call; + assert.equal(notices(t.fw).length, before, 'no notice'); + assert.doesNotMatch(storedResult(t, toolUseId), /peek\(\)/); + } finally { await t.done(); } +}); + +test('an ended record two statements report before either is told is told once', async () => { + const { t, holding, held } = await holdingServer(); + try { + holding.add('toolsets/list'); + const looks = [look(t.fw), look(t.fw)]; + await waitFor(() => held.length === 2, 'both looks asked the server'); + holding.clear(); + t.state.ended = [{ id: 'e1', toolset: 'game', instance: 'g1', reason: 'the game is over' }]; + chunk(t, { id: held[0]!.id, result: t.state.listResult() }, { id: held[1]!.id, result: t.state.listResult() }); + await Promise.all(looks); + await roundTrip(t); + assert.equal(windowText(t.fw.framework).join('\n').split('[arc/game:g1] ended: the game is over.').length - 1, 1); + } finally { await t.done(); } +}); + +test('an ended record that expires while its note waits is still told once', async () => { + const { t, holding, held } = await holdingServer(); + try { + holding.add('toolsets/list'); + const looks = [look(t.fw), look(t.fw)]; + await waitFor(() => held.length === 2, 'both looks asked the server'); + holding.clear(); + t.state.ended = [{ id: 'e1', toolset: 'game', instance: 'g1', reason: 'the game is over' }]; + const first = t.state.listResult(); + t.state.ended = []; + chunk(t, { id: held[0]!.id, result: first }, { id: held[1]!.id, result: t.state.listResult() }); + await Promise.all(looks); + await roundTrip(t); + assert.equal(windowText(t.fw.framework).join('\n').split('[arc/game:g1] ended: the game is over.').length - 1, 1); + } finally { await t.done(); } +}); + +test('a recovery\'s tools/list change is told as it is read, before a hint read after it in the same chunk', async () => { + const { t, holding, held } = await holdingServer(); + try { + holding.add('tools/list'); + const before = notices(t.fw).length; + t.state.next(); + t.server.peer.notify('tools/hints', t.state.hint([])); + await waitFor(() => held.length === 1, 'the recovery\'s tools/list asked for'); + holding.clear(); + const tools = TOOLS.map((x) => (x.name === 'peek' ? { ...x, _meta: { ...(x._meta as Json), 'mcpl/visible': true } } : x)); + chunk(t, { id: held[0]!.id, result: { tools, _meta: { 'mcpl/rev': t.state.rev } } }, + { method: 'tools/hints', params: t.state.hint([{ name: 'peek', visible: false }]) }); + await waitFor(() => notices(t.fw).slice(before).some((x) => x.includes('now unavailable: peek')), 'the hint\'s change told'); + const told = notices(t.fw).slice(before); + const shown = told.findIndex((x) => x.includes('peek()')); + assert.ok(shown >= 0 && shown < told.findIndex((x) => x.includes('now unavailable: peek')), 'the list\'s change first'); + } finally { await t.done(); } +}); + +test('a change a filtered look hands back is told before a change read after it in the same chunk', async () => { + const { t, holding, held } = await holdingServer(); + try { + holding.add('toolsets/list'); + const before = notices(t.fw).length; + const pending = look(t.fw, { server: 'arc', tool: 'status' }); + await waitFor(() => held.length === 1, 'look asked the server'); + holding.clear(); + t.state.statement = [{ name: 'dig', visible: true }, { name: 'peek', visible: true }]; + chunk(t, { id: held[0]!.id, result: t.state.listResult() }, + { method: 'tools/hints', params: t.state.hint([{ name: 'peek', visible: false }]) }); + await pending; + await waitFor(() => notices(t.fw).slice(before).some((x) => x.includes('now unavailable: peek')), 'the later change told'); + const told = notices(t.fw).slice(before); + const shown = told.findIndex((x) => x.includes('peek()')); + assert.ok(shown >= 0 && shown < told.findIndex((x) => x.includes('now unavailable: peek')), 'look\'s change first'); + } finally { await t.done(); } +}); + +test('an instance reopened under an ended instance\'s name is told closed when it goes', async () => { + const t = await setup(); + const told = (text: string) => windowText(t.fw.framework).join('\n').split(text).length - 1; + try { + t.state.open = [{ toolset: 'game', instance: 'g1', title: 'G1' }]; + await look(t.fw); + t.state.open = []; + t.state.ended = [{ id: 'e1', toolset: 'game', instance: 'g1', reason: 'the game is over' }]; + await look(t.fw); + await waitFor(() => told('[arc/game:g1] ended: the game is over.') === 1, 'the end told'); + t.state.open = [{ toolset: 'game', instance: 'g1', title: 'G1' }]; + await look(t.fw); + t.state.open = []; + const closed = told('[arc/game:g1] is closed'); + await look(t.fw); + await roundTrip(t); + assert.equal(told('[arc/game:g1] is closed') - closed, 1, 'the reopened instance told closed'); + assert.equal(told('[arc/game:g1] ended: the game is over.'), 1, 'the end not told again'); + } finally { await t.done(); } +}); + +test('a notice describes a definition it shows in two instances only once', async () => { + const t = await setup(); + try { + t.state.open = [{ toolset: 'game', instance: 'g1', title: 'G1' }, { toolset: 'game', instance: 'g2', title: 'G2' }]; + await look(t.fw); + const before = notices(t.fw).length; + t.server.peer.notify('tools/hints', t.state.hint([{ name: 'play', instance: 'g1', visible: true }, { name: 'play', instance: 'g2', visible: true }])); + await waitFor(() => notices(t.fw).slice(before).some((x) => x.includes('[arc/game:g2]')), 'the change told'); + const text = notices(t.fw).slice(before).join('\n'); + assert.match(text, /\[arc\/game:g1\]/); + assert.equal(text.split('play(').length - 1, 2, 'the tool in each'); + assert.equal(text.split('Play a point.').length - 1, 1, 'its first sentence once'); + } finally { await t.done(); } +}); + +test('a hint restating the presence a recovery\'s tools/list moved a tool to leaves the move to be told at the next refresh', async () => { + const tools: Json[] = TOOLS.map((x) => ({ ...x })); + const t = await setup(() => false, {}, ADVERT, tools); + const told = () => notices(t.fw).filter((x) => x.includes('peek is in your tool list now')).length; + try { + present(tools, 'peek', 'static'); + const lists = t.methods().filter((m) => m === 'toolsets/list').length; + t.state.next(); + t.server.peer.notify('tools/hints', t.state.hint([])); + await waitFor(() => t.methods().filter((m) => m === 'toolsets/list').length > lists, 'a skipped rev recovered'); + t.server.peer.notify('tools/hints', t.state.hint([{ name: 'peek', presence: 'static' }])); + await roundTrip(t); + assert.equal(told(), 0, 'not before the refresh'); + t.server.peer.notify('notifications/tools/list_changed', {}); + await waitFor(() => t.fw.framework.getAllTools().some((x) => x.name === 'arc--peek'), 'the refresh shows peek'); + await roundTrip(t); + assert.equal(told(), 1, 'told once, at the refresh'); + } finally { await t.done(); } +}); + +test('a definition a hint gives after a separate hinted move is used once the tool list makes the tool dynamic', async () => { + const tools: Json[] = TOOLS.map((x) => ({ ...x })); + const t = await setup(() => false, {}, ADVERT, tools); + try { + const definition = { description: 'Status, from a hint.', inputSchema: { type: 'object', properties: { loud: { type: 'boolean' } } } }; + t.server.peer.notify('tools/hints', t.state.hint([{ name: 'status', presence: 'dynamic' }])); + t.server.peer.notify('tools/hints', t.state.hint([{ name: 'status', definition }])); + // The server's own statement restates the definition in force (RFC §6.1), as look's re-read reads it. + t.state.statement.push({ name: 'status', visible: true, definition }); + await roundTrip(t); + present(tools, 'status', 'dynamic'); + const status = tools.find((x) => x.name === 'status')!; + status._meta = { ...(status._meta as Json), 'mcpl/visible': true }; + t.server.peer.notify('notifications/tools/list_changed', {}); + await waitFor(() => !t.fw.framework.getAllTools().some((x) => x.name === 'arc--status'), 'the tool list makes status dynamic'); + const refused = await callTool(t.fw.framework, 'toolsets--act', { server: 'arc', tool: 'status', args: { loud: 'yes' } }); + assert.ok(refused.isError, 'checked against the hint\'s definition'); + assert.match(resultText(refused), /loud/); + assert.match(await look(t.fw, { server: 'arc', tool: 'status' }), /Status, from a hint\./); + } finally { await t.done(); } +}); + +test('a tool listed twice is known by its first listing, which act checks arguments against', async () => { + const tools: Json[] = [...TOOLS.filter((x) => x.name !== 'peek'), + { name: 'peek', description: 'The first listing.', inputSchema: { type: 'object', properties: { far: { type: 'boolean' } } }, _meta: { 'mcpl/presence': 'dynamic', 'mcpl/visible': true } }, + { name: 'peek', description: 'The second listing.', inputSchema: { type: 'object', properties: { far: { type: 'string' } } }, _meta: { 'mcpl/presence': 'dynamic', 'mcpl/visible': true } }]; + const t = await setup(() => false, {}, ADVERT, tools); + try { + assert.match(await look(t.fw, { server: 'arc', tool: 'peek' }), /The first listing\./); + const refused = await callTool(t.fw.framework, 'toolsets--act', { server: 'arc', tool: 'peek', args: { far: 'yes' } }); + assert.ok(refused.isError, 'checked against the first listing\'s schema'); + } finally { await t.done(); } +}); + +test('a first sentence is cut by characters, never inside one', async () => { + const tools: Json[] = [...TOOLS.filter((x) => x.name !== 'peek'), + { name: 'peek', description: `${'x'.repeat(138)}\u{1F600}\u{1F600}\u{1F600} and more`, inputSchema: { type: 'object', properties: {} }, _meta: { 'mcpl/presence': 'dynamic', 'mcpl/visible': false } }]; + const t = await setup(() => false, {}, ADVERT, tools); + try { + const before = notices(t.fw).length; + t.server.peer.notify('tools/hints', t.state.hint([{ name: 'peek', visible: true }])); + await waitFor(() => notices(t.fw).slice(before).some((x) => x.includes('peek()')), 'peek announced'); + const line = notices(t.fw).slice(before).join('\n').split('\n').find((x) => x.includes('peek()'))!; + assert.match(line, /x\u{1F600}…$/u, 'cut after a whole character'); + assert.doesNotMatch(line, /\uFFFD/, 'nothing replaced for a broken character'); + } finally { await t.done(); } +}); + +test('a group the host opened, which the manifest then declares and later drops, is no longer open: act is refused', async () => { + let advert: Json = ADVERT; + const t = await setup((msg, peer) => { + if (msg.method === 'mcpl/manifest') { peer.reply(msg.id, { version: '0.5', pushEvents: true, ...advert }); return true; } + return false; + }); + const manifests = () => t.methods().filter((m) => m === 'mcpl/manifest').length; + const changeManifest = async (next: Json) => { + const n = manifests(); + advert = next; + t.server.peer.notify('mcpl/manifestChanged', { revision: `r${n + 2}`, domains: ['toolsets'] }); + await waitFor(() => manifests() > n, 'the manifest fetched again'); + await roundTrip(t); + }; + try { + await callTool(t.fw.framework, 'toolsets--open', { server: 'arc', toolset: 'cave' }); + await changeManifest({ ...ADVERT, toolsets: { ...(ADVERT.toolsets as Json), cave: { title: 'Cave' } } }); + await changeManifest(ADVERT); + const gated = await callTool(t.fw.framework, 'toolsets--act', { server: 'arc', tool: 'dig', args: {} }); + assert.ok(gated.isError); + assert.match(resultText(gated), /dig is in cave, which isn't open/); + } finally { await t.done(); } +}); + +/** JSON nested `depth` levels deep, written as text: a server that writes JSON without JSON.stringify can send it. */ +const deepObjectJson = (depth: number) => `${'{"type":"object","properties":{"a":'.repeat(depth)}{"type":"string"}${'}}'.repeat(depth)}`; +const deepArrayJson = (depth: number) => `${'{"type":"array","items":'.repeat(depth)}{"type":"string"}${'}'.repeat(depth)}`; +const rawLine = (t: Awaited>, line: string) => { + const connection = (t.fw.framework as unknown as { mcplServerRegistry: { getServer(id: string): unknown } }).mcplServerRegistry.getServer('arc'); + (connection as { transport: { emit(event: string, line: string): void } }).transport.emit('line', line); +}; + +test('a tool a server describes malformed (a description that isn\'t a string, a schema nested very deep) is still announced and looked at', async () => { + const { t, holding, held } = await holdingServer(); + try { + holding.add('tools/list'); + const before = notices(t.fw).length; + t.server.peer.notify('notifications/tools/list_changed', {}); + await waitFor(() => held.length === 1, 'the tool list asked for'); + holding.clear(); + const listed = TOOLS.filter((x) => x.name !== 'peek').map((x) => JSON.stringify(x)).join(','); + const dynamic = '"_meta":{"mcpl/presence":"dynamic","mcpl/visible":true}'; + rawLine(t, `{"jsonrpc":"2.0","id":${JSON.stringify(held[0]!.id)},"result":{"tools":[${listed},` + + `{"name":"peek","description":42,"inputSchema":{"type":"object","properties":{}},${dynamic}},` + + `{"name":"delve","description":"Delve.","inputSchema":${deepObjectJson(20000)},${dynamic}},` + + `{"name":"burrow","description":"Burrow.","inputSchema":{"type":"object","properties":{"rows":${deepArrayJson(20000)}}},${dynamic}}],` + + `"_meta":{"mcpl/rev":${t.state.rev}}}}`); + await waitFor(() => notices(t.fw).slice(before).some((x) => x.includes('burrow(')), 'the tools announced'); + const text = notices(t.fw).slice(before).join('\n'); + assert.match(text, /peek\(\)/); + assert.match(text, /delve\(a\?: .*\) — Delve\./); + assert.match(text, /burrow\(rows\?: .*\) — Burrow\./); + assert.match(await look(t.fw, { server: 'arc', tool: 'delve' }), /inputSchema:\n\(nested too deeply to show\)/); + } finally { await t.done(); } +}); + From a9fbdf152ed17c3672147a3dbe1a112fcd70aafb Mon Sep 17 00:00:00 2001 From: Octavia-2412 Date: Sun, 25 Oct 2026 23:37:51 -0400 Subject: [PATCH 21/46] fix(mcpl): a structured-only result reads as JSON only where its tool change joins it executeMcplToolCall chose the JSON view of a structured-only result whenever it had taken a tool change, before withWholeChange decided whether the change fits under the call's inline cap. Where it doesn't, the change goes as a notice, and the result still read as JSON, where the same result with no change reads as 0.27.0 read it (no content). The view is there so a change doesn't stand in place of the content; without the join there's nothing for it to do. Now the view is chosen only where the change joins the result, and otherwise the result reads as 0.27.0's: a result reads as 0.27.0's unless something is appended to it. Test: a toolHints server answers the puppet's call with structured content alone, and the change its hints made won't fit after it under a 1000-character cap: the puppet stores the result as 0.27.0 stored it (an empty content), and the change is told as a notice. It fails when the view is kept where the change doesn't join. Co-Authored-By: Claude Opus 5.5 --- src/framework.ts | 11 ++++++----- test/mcpl-toolsets-wiring.test.ts | 13 +++++++++++++ 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index 9d7eae55a..09b0bffe9 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -22420,13 +22420,13 @@ export class AgentFramework { const toolsets = taker ? this.toolsetHub?.get(serverId) : undefined; const cs = toolsets?.takeChange(result); if (toolsets && cs && !isEmptyChange(cs)) taken = { toolsets, cs }; - const withChange = (answer: ToolResult): ToolResult => { + const withChange = (answer: ToolResult, alone: ToolResult = answer): ToolResult => { if (!taken) return answer; const joined = this.withWholeChange(agentName!, call, answer, taken.toolsets.presence.render(taken.cs, { mark: false }), call.resultMaxChars); if (joined) taker!(taken); else taken.toolsets.tell(taken.cs); taken = undefined; - return joined ?? answer; + return joined ?? alone; }; // A server's error is a failure here too, as the model's dispatch // reads it: its text (mcplToolFailure), without its other blocks, and @@ -22451,10 +22451,11 @@ export class AgentFramework { await this.autoFetchReferences(result.content, serverId, 'register') .catch((e) => console.error('[rfc005] reference registration error:', (e as Error).message)); const blocks = Array.isArray(direct.data) ? direct.data : []; - const read: ToolResult = taken && blocks.length === 0 && Object.prototype.hasOwnProperty.call(direct, 'structured') + const read: ToolResult = Array.isArray(direct.data) ? { ...direct, dataIsContent: true } : direct; + const shown: ToolResult = taken && blocks.length === 0 && Object.prototype.hasOwnProperty.call(direct, 'structured') ? { ...direct, data: JSON.stringify(direct.structured) ?? 'null' } - : Array.isArray(direct.data) ? { ...direct, dataIsContent: true } : direct; - return withChange(read); + : read; + return withChange(shown, read); } return withChange(direct); } catch (err) { diff --git a/test/mcpl-toolsets-wiring.test.ts b/test/mcpl-toolsets-wiring.test.ts index 64e3b6750..c5679a62d 100644 --- a/test/mcpl-toolsets-wiring.test.ts +++ b/test/mcpl-toolsets-wiring.test.ts @@ -205,6 +205,19 @@ test('the puppet stores a result that is structured content alone as that conten } }); +test('the puppet stores a structured-only result as 0.27.0 did when its tool change won\'t fit under the cap, and the change goes as a notice', async () => { + const { server } = await hintingServer({ content: [], structuredContent: { s: 'x'.repeat(980) } }); + const fw = await connectedTo(server, { toolResultInlineMaxChars: 1000 }); + try { + const { toolUseId } = await fw.framework.puppetToolCall('scout', 'arc--status', {}); + assert.equal(storedResult(fw, toolUseId), '""', 'stored as 0.27.0 stored it'); + await waitFor(() => notices(fw).some((x) => x.includes('peek()')), 'the change told as a notice'); + } finally { + await fw.stop(); + await server.close(); + } +}); + test('the puppet\'s toolsets--act stores the dynamic tool\'s result with the tool change its call made, and the change isn\'t told again', async () => { const { server } = await hintingServer({ content: [{ type: 'text', text: 'peeked' }] }); const fw = await connectedTo(server); From ac19444c6e3d835a4077762ac46b26d1fc6d6475 Mon Sep 17 00:00:00 2001 From: Prudence-2217 Date: Sun, 25 Oct 2026 23:44:19 -0400 Subject: [PATCH 22/46] test(mcpl): a host-opened group the manifest declares is pinned where close meets it The in-process test of close with nothing named, after a group the host opened was declared, goes. The caller-level test that opens an undeclared group, then has the manifest declare and drop it, now also asks toolsets--close with nothing named in the declared state, and checks that it answers that nothing is open on the server. The test of a notice describing a definition shown in two instances once now waits for the opening look's own notice before counting, and for the hint's notice by the tool's line. It had waited for any notice naming the second instance, which the look's own notice names too. Run alone on a loaded host, it failed 3 of 6 runs, each finding no line for the tool; with the narrower waits it passed 8 of 8. Co-Authored-By: Claude Opus 5.5 --- test/mcpl-tool-hints.test.ts | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/test/mcpl-tool-hints.test.ts b/test/mcpl-tool-hints.test.ts index 9ca24e4e5..ec16d1fd6 100644 --- a/test/mcpl-tool-hints.test.ts +++ b/test/mcpl-tool-hints.test.ts @@ -1295,15 +1295,6 @@ test('a push\'s change that nothing takes is told as a notice (in process: a pus assert.match(told.join('\n'), /peek\(\)/); }); -test('close with nothing named, after a group the host opened was declared, says nothing is open', async () => { - const { hub, c } = tappedController(); - c.setGroupOpen('cave', true); - c.manifestChanged({ toolHints: true, toolsets: { cave: { title: 'Cave' } } }); - const result = await new ToolsetsModule(hub, () => null).handleToolCall({ id: 'c1', name: 'close', input: { server: 'arc' } } as never); - assert.ok(result.isError); - assert.match(String(result.error), /nothing is open on arc/); -}); - test('a tool a recovery\'s list puts in a toolset leaves the model\'s tool list at the next refresh, and is told so', async () => { const tools: Json[] = TOOLS.map((x) => ({ ...x })); const t = await setup(() => false, {}, ADVERT, tools); @@ -1833,9 +1824,11 @@ test('a notice describes a definition it shows in two instances only once', asyn try { t.state.open = [{ toolset: 'game', instance: 'g1', title: 'G1' }, { toolset: 'game', instance: 'g2', title: 'G2' }]; await look(t.fw); + await waitFor(() => notices(t.fw).some((x) => x.includes('[arc/game:g2]')), 'look\'s change told'); const before = notices(t.fw).length; t.server.peer.notify('tools/hints', t.state.hint([{ name: 'play', instance: 'g1', visible: true }, { name: 'play', instance: 'g2', visible: true }])); - await waitFor(() => notices(t.fw).slice(before).some((x) => x.includes('[arc/game:g2]')), 'the change told'); + await waitFor(() => notices(t.fw).slice(before).some((x) => x.includes('play(')), 'the change told'); + await roundTrip(t); const text = notices(t.fw).slice(before).join('\n'); assert.match(text, /\[arc\/game:g1\]/); assert.equal(text.split('play(').length - 1, 2, 'the tool in each'); @@ -1911,7 +1904,7 @@ test('a first sentence is cut by characters, never inside one', async () => { } finally { await t.done(); } }); -test('a group the host opened, which the manifest then declares and later drops, is no longer open: act is refused', async () => { +test('a group the host opened, which the manifest then declares, is no longer open: close says nothing is open, and once the manifest drops it act is refused', async () => { let advert: Json = ADVERT; const t = await setup((msg, peer) => { if (msg.method === 'mcpl/manifest') { peer.reply(msg.id, { version: '0.5', pushEvents: true, ...advert }); return true; } @@ -1928,6 +1921,9 @@ test('a group the host opened, which the manifest then declares and later drops, try { await callTool(t.fw.framework, 'toolsets--open', { server: 'arc', toolset: 'cave' }); await changeManifest({ ...ADVERT, toolsets: { ...(ADVERT.toolsets as Json), cave: { title: 'Cave' } } }); + const closed = await callTool(t.fw.framework, 'toolsets--close', { server: 'arc' }); + assert.ok(closed.isError); + assert.match(resultText(closed), /nothing is open on arc/); await changeManifest(ADVERT); const gated = await callTool(t.fw.framework, 'toolsets--act', { server: 'arc', tool: 'dig', args: {} }); assert.ok(gated.isError); From bccb27f1fec4eb30324aec2099b454b5f93beccd Mon Sep 17 00:00:00 2001 From: Octavia-2412 Date: Mon, 26 Oct 2026 00:00:45 -0400 Subject: [PATCH 23/46] test(mcpl): the late-close test waits for the close to reach the server "a close answered after its timeout still closes the instance it named, and is told" asserted that the server held the close as soon as the host's 300 ms timeout had rejected it. The scripted server runs on the same event loop, so on a loaded host the close can reach it after the timeout, and then that assertion fails. The test failed once in four runs of the toolsets files, on an assertion that wasn't captured; 98 runs here under load (alone, ten at once, and the whole file six at once) didn't fail it. Whether the server sees the close before or after the timeout isn't what the test is about: what it pins is that an answer coming after the timeout still closes the instance and is told. It now waits for the close to reach the server, then answers it, which is after the timeout either way. The timeout stays as it was. With the server seeing the close 400 ms after it arrives, past the timeout, the earlier form failed at "the close reached the server" in 3 of 3 runs and this one passed 3 of 3; this one passed 10 of 10 as written. A hinted request that keeps no toolset, or no instance, still fails it: the late close is never told. Co-Authored-By: Claude Opus 5.5 --- test/mcpl-tool-hints.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/mcpl-tool-hints.test.ts b/test/mcpl-tool-hints.test.ts index ec16d1fd6..35a312c07 100644 --- a/test/mcpl-tool-hints.test.ts +++ b/test/mcpl-tool-hints.test.ts @@ -328,7 +328,7 @@ test('a close answered after its timeout still closes the instance it named, and const connection = (t.fw.framework as unknown as { mcplServerRegistry: { getServer(id: string): { sendToolsetsClose(p: Json, ms: number): Promise } } }) .mcplServerRegistry.getServer('arc'); await assert.rejects(connection.sendToolsetsClose({ toolset: 'game', instance: 'g1' }, 300)); - assert.ok(held, 'the close reached the server'); + await waitFor(() => held !== null, 'the close reached the server'); const h = held as unknown as { id: unknown; peer: ScriptedPeer }; h.peer.reply(h.id, { content: [] }); await waitFor(() => notices(t.fw).some((x) => x.includes('[arc/game:g1] closed.')), 'the late close told'); From bea7819067f2bd4977c7789217e119a505ca562a Mon Sep 17 00:00:00 2001 From: Prudence-2217 Date: Mon, 26 Oct 2026 00:08:49 -0400 Subject: [PATCH 24/46] test(mcpl): the manifest test waits for each manifest change to be applied, not only fetched The test of a host-opened group that the manifest declares and then drops waited for the server to receive each mcpl/manifest request, then one round trip. Applying a fetched manifest takes further steps (a feature-set update request among them) before the toolset state changes, so under load toolsets--act could run before the dropping manifest was applied, and go through: in 18 runs of the hints file, six at once, the act's "isError" assertion failed once. Holding the server's answer to the dropping manifest back 400 ms made it fail 3 of 3. Each change now also waits for the tools/list the host asks for once it has applied the manifest, then a round trip. With the same 400 ms hold it passes 3 of 3; as written, 10 of 10 at ten at once, and the hints file 12 of 12, six at once, on a host at load 77. With the group not forgotten when the manifest declares it, the test still fails (2 of 2), at the close that should answer that nothing is open. Co-Authored-By: Claude Opus 5.5 --- test/mcpl-tool-hints.test.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/test/mcpl-tool-hints.test.ts b/test/mcpl-tool-hints.test.ts index 35a312c07..7a916a689 100644 --- a/test/mcpl-tool-hints.test.ts +++ b/test/mcpl-tool-hints.test.ts @@ -1910,12 +1910,14 @@ test('a group the host opened, which the manifest then declares, is no longer op if (msg.method === 'mcpl/manifest') { peer.reply(msg.id, { version: '0.5', pushEvents: true, ...advert }); return true; } return false; }); - const manifests = () => t.methods().filter((m) => m === 'mcpl/manifest').length; + const count = (method: string) => t.methods().filter((m) => m === method).length; + // A manifest change is applied once the host asks for the tool list it rebuilds after it, which + // comes after the fetch (held back to one per five seconds) and its feature-set update. const changeManifest = async (next: Json) => { - const n = manifests(); + const [manifests, lists] = [count('mcpl/manifest'), count('tools/list')]; advert = next; - t.server.peer.notify('mcpl/manifestChanged', { revision: `r${n + 2}`, domains: ['toolsets'] }); - await waitFor(() => manifests() > n, 'the manifest fetched again'); + t.server.peer.notify('mcpl/manifestChanged', { revision: `r${manifests + 2}`, domains: ['toolsets'] }); + await waitFor(() => count('mcpl/manifest') > manifests && count('tools/list') > lists, 'the manifest fetched again and applied'); await roundTrip(t); }; try { From 59ca752c6a4e3b2694fb6eac53f88c8644cabc01 Mon Sep 17 00:00:00 2001 From: Prudence-2217 Date: Mon, 26 Oct 2026 02:28:06 -0400 Subject: [PATCH 25/46] fix(mcpl): rendering is total for literals and look's definition, a restating look counts as shown only what it showed, and an unused seam goes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From a review of the whole change against 0.27.0: - A schema's literal values (an enum's values, a const, a default) are written through one guarded helper, so a value nested too deeply to write shows as … instead of throwing. Under Node 24 a const, enum value or default nested 20,000 levels threw RangeError from signature rendering, failing the notice or look that rendered it. - look naming a tool shows a description only when it is a string, and only the string entries of mcpl/class. A description of 42 printed as 42, and an object whose toString isn't callable, as a description or a class entry, threw TypeError. - sendToolsList's awaiting path gathers pages with a loop, as its read hook does, so a page too large to spread into one call's arguments can't throw after the hook has applied the list. - A look that restates the present hands its taker each server's change without the change's blocks. look shows the live catalog, which counts what it rendered as shown; the change's blocks weren't shown, so a tool a hint hid while look waited on another server kept its first sentence counted as shown and lost it from the next announcement. - ToolsetController.takeChangeText goes: no production code called it, and the two in-process tests that did now take the change and render it as the production callers do. Tests: - a tool whose schema holds a literal nested 20,000 levels deep (const, enum, default), or whose description and class are objects, is announced and looked at (times out waiting for the announcement without the fix); - a look that restates the present, with a hint hiding a tool while it waits on a second server, leaves that tool's first sentence owed (fails without the fix: the next announcement lacks it); - a first sentence that reached the model is left out of the next announcement of the same definition (passes before and after: it pins what the shown set is for); - the paged-lists test checks the first page through look, where the controller's inventory is met, rather than the framework's own list; - the deep-options look test asserts the options line; - each negative verdict after the 200 ms recovery debounce also waits for a round trip, so the server has read whatever the host sent before it. No test makes a page past V8's argument limit. By run (Linux, Node 24.22.1): typecheck clean; the three toolset files pass 128 of 128, twice. Co-Authored-By: Claude Opus 5.5 --- src/mcpl/server-connection.ts | 2 +- src/mcpl/tool-signature.ts | 20 ++++-- src/mcpl/toolset-host.ts | 10 --- src/mcpl/toolsets-module.ts | 3 +- test/mcpl-tool-hints.test.ts | 114 ++++++++++++++++++++++++++++++---- 5 files changed, 119 insertions(+), 30 deletions(-) diff --git a/src/mcpl/server-connection.ts b/src/mcpl/server-connection.ts index 80ef58efe..3c8656ce9 100644 --- a/src/mcpl/server-connection.ts +++ b/src/mcpl/server-connection.ts @@ -1758,7 +1758,7 @@ export class McplServerConnection extends EventEmitter { throw malformed('no tools array'); } first ??= result; - tools.push(...(result.tools as McpToolDefinition[])); + for (const t of result.tools as McpToolDefinition[]) tools.push(t); if (result.nextCursor === undefined || result.nextCursor === null) { // The first page's other fields are kept as they came, so a // single-page answer is returned exactly as before. diff --git a/src/mcpl/tool-signature.ts b/src/mcpl/tool-signature.ts index d2a1deef6..f26a9540b 100644 --- a/src/mcpl/tool-signature.ts +++ b/src/mcpl/tool-signature.ts @@ -24,12 +24,21 @@ export interface HintedTool { const isObj = (v: unknown): v is Record => typeof v === 'object' && v !== null && !Array.isArray(v); +/** A schema's literal value (an enum's, a const or a default) as JSON, or … where it's nested too deeply to write. */ +function literal(value: unknown): string { + try { + return JSON.stringify(value) ?? 'null'; + } catch { + return '…'; + } +} + function typeSig(schema: JsonSchema | undefined, depth = 0): string { if (!schema || typeof schema !== 'object') return 'any'; if (depth > 8) return '…'; if (Array.isArray(schema.enum)) { // The enum is often the menu (a decision's options): all of it unless it is long. - const vals = (schema.enum as unknown[]).map((v) => JSON.stringify(v)); + const vals = (schema.enum as unknown[]).map(literal); if (vals.length <= 8 || vals.join('|').length <= 400) return vals.join('|'); let shown = 0; let len = 0; @@ -39,7 +48,7 @@ function typeSig(schema: JsonSchema | undefined, depth = 0): string { } return `${vals.slice(0, Math.max(shown, 4)).join('|')}|…(${vals.length} in all)`; } - if (schema.const !== undefined) return JSON.stringify(schema.const); + if (schema.const !== undefined) return literal(schema.const); const alts = (schema.oneOf ?? schema.anyOf) as JsonSchema[] | undefined; if (Array.isArray(alts)) return alts.map((s) => typeSig(s, depth + 1)).join(' | '); const t = Array.isArray(schema.type) ? (schema.type as string[]).join('|') : (schema.type as string | undefined); @@ -82,7 +91,7 @@ export function signature(tool: HintedTool): string { const props = (isObj(s.properties) ? s.properties : {}) as Record; const req = new Set(Array.isArray(s.required) ? (s.required as string[]) : []); const params = Object.entries(props).map(([k, v]) => { - const def = isObj(v) && v.default !== undefined ? ` =${JSON.stringify(v.default)}` : ''; + const def = isObj(v) && v.default !== undefined ? ` =${literal(v.default)}` : ''; return `${k}${req.has(k) ? '' : '?'}: ${typeSig(v, 1)}${def}`; }); return `${tool.name}(${params.join(', ')})`; @@ -154,10 +163,11 @@ export function jsonText(value: unknown, space?: number): string { /** A tool's signature, description, class and input schema, for `look` naming a tool. */ export function renderToolDefinition(tool: HintedTool): string { const cls = tool._meta?.['mcpl/class']; + const classes = Array.isArray(cls) ? cls.filter((c): c is string => typeof c === 'string') : []; return [ signature(tool), - tool.description ?? '', - ...(Array.isArray(cls) ? [`class: ${cls.join(', ')}`] : []), + typeof tool.description === 'string' ? tool.description : '', + ...(classes.length ? [`class: ${classes.join(', ')}`] : []), 'inputSchema:', jsonText(tool.inputSchema ?? {}, 2), ] diff --git a/src/mcpl/toolset-host.ts b/src/mcpl/toolset-host.ts index d652341f2..7a310c5bf 100644 --- a/src/mcpl/toolset-host.ts +++ b/src/mcpl/toolset-host.ts @@ -425,16 +425,6 @@ export class ToolsetController { return this.readChanges.get(carrier) ?? emptyChange(); } - /** takeChange's change rendered, for a caller that shows it with the carrier - * at once, so it counts as told; '' for nothing. */ - takeChangeText(carrier: unknown): string { - const cs = this.takeChange(carrier); - if (isEmptyChange(cs)) return ''; - const text = this.presence.render(cs); - this.told(cs); - return text; - } - /** A change its taker showed to an agent: its ended records count as told, and the definitions it describes as shown. */ told(cs: ChangeSet): void { this.presence.markTold(cs.ended); diff --git a/src/mcpl/toolsets-module.ts b/src/mcpl/toolsets-module.ts index dc3026de6..bc0b73e6a 100644 --- a/src/mcpl/toolsets-module.ts +++ b/src/mcpl/toolsets-module.ts @@ -415,7 +415,8 @@ export class ToolsetsModule implements Module { const lines = restates ? read.flatMap(({ c, cs }) => { const said = [...cs.suggest.map((s) => c.presence.renderSuggestion(s)), ...c.presence.untoldNotes(cs), ...cs.notes]; - take?.({ toolsets: c, cs }); + // The catalog above counted what it showed; the change's own blocks weren't shown. + take?.({ toolsets: c, cs: { ...cs, changes: [] } }); return said; }) : []; diff --git a/test/mcpl-tool-hints.test.ts b/test/mcpl-tool-hints.test.ts index 7a916a689..6f97ec8d6 100644 --- a/test/mcpl-tool-hints.test.ts +++ b/test/mcpl-tool-hints.test.ts @@ -150,7 +150,8 @@ test('vector 3, 11: a push\'s hints apply on arrival whatever is decided about t const lists = t.methods().filter((m) => m === 'tools/list').length; t.server.peer.notify('tools/hints', t.state.hint([{ name: 'peek', visible: true }])); await waitFor(() => windowText(t.fw.framework).some((x) => x.includes('tool changes') && x.includes('peek()')), 'the next change applied, and told'); - await settle(400); + await settle(400); // past the 200 ms recovery debounce + await roundTrip(t); // and the server has read whatever the host sent before it assert.equal(t.methods().filter((m) => m === 'tools/list').length, lists, 'no recovery'); } finally { await t.done(); } }); @@ -292,7 +293,8 @@ test('vector 27: open, close and list results take the next rev; tools/list take const closed = await callTool(t.fw.framework, 'toolsets--close', { server: 'arc' }); assert.match(resultText(closed), /packed up\n\[arc\/build\] closed\. Now unavailable: place\./); await look(t.fw); // a toolsets/list, rev after the close's - await settle(400); + await settle(400); // past the 200 ms recovery debounce + await roundTrip(t); // and the server has read whatever the host sent before it assert.equal(lists(), before + 1, 'every rev was the next one: no recovery'); } finally { await t.done(); } }); @@ -974,7 +976,8 @@ test('revs start over on each connection: after a reconnect, the new connection\ const before = notices(t.fw).length; t.server.peer.notify('tools/hints', t.state.hint([{ name: 'peek', visible: false }])); await waitFor(() => notices(t.fw).slice(before).some((x) => x.includes('now unavailable: peek')), 'the new connection\'s change told'); - await settle(400); + await settle(400); // past the 200 ms recovery debounce + await roundTrip(t); // and the server has read whatever the host sent before it assert.ok(!t.methods().slice(from).some((m) => m === 'tools/list' || m === 'toolsets/list'), 'no recovery'); } finally { await t.done(); } }); @@ -1041,10 +1044,10 @@ test('taking a result\'s change first tells, as a notice, the change of a result readCall(callResult(1, [{ name: 'peek', visible: true }])); const later = callResult(2, [{ name: 'peek', visible: false }]); readCall(later); - const shown = c.takeChangeText(later); + const taken = c.takeChange(later); assert.equal(told.length, 1, 'the earlier change, told first'); assert.match(told[0]!, /peek\(\)/); - assert.match(shown, /now unavailable: peek/); + assert.match(c.presence.render(taken), /now unavailable: peek/); }); test('a hint read right after a tools/list\'s last page, in the same chunk, isn\'t undone by that list', async () => { @@ -1114,7 +1117,8 @@ test('a toolsets/list naming a tool tools/list hasn\'t listed is recovered from, await look(t.fw); await waitFor(() => t.server.received.slice(from).filter((m) => m.method === 'toolsets/list').length >= 2, 'the recovery read toolsets/list'); await roundTrip(t); - await settle(400); + await settle(400); // past the 200 ms recovery debounce + await roundTrip(t); // and the server has read whatever the host sent before it const after = t.server.received.slice(from).map((m) => m.method).filter((m) => m === 'tools/list' || m === 'toolsets/list'); assert.deepEqual(after, ['toolsets/list', 'tools/list', 'toolsets/list'], 'look\'s read, then one recovery: its own toolsets/list, naming lantern again, starts no other'); } finally { await t.done(); } @@ -1134,7 +1138,8 @@ test('a recovery whose tools/list fails reads no toolsets/list', async () => { t.server.peer.notify('tools/hints', t.state.hint([])); await waitFor(() => t.server.received.slice(from).some((m) => m.method === 'tools/list'), 'the recovery read tools/list'); await roundTrip(t); - await settle(400); + await settle(400); // past the 200 ms recovery debounce + await roundTrip(t); // and the server has read whatever the host sent before it assert.ok(!t.server.received.slice(from).some((m) => m.method === 'toolsets/list')); } finally { await t.done(); } }); @@ -1169,7 +1174,6 @@ test('two paged tools/lists read at once each apply whole, never mixed', async ( pages.push({ msg, peer }); return true; }); - const listed = () => t.fw.framework.getAllTools().map((x) => x.name); try { paging = true; const before = notices(t.fw).length; @@ -1185,7 +1189,7 @@ test('two paged tools/lists read at once each apply whole, never mixed', async ( await waitFor(() => pages.length >= 3, 'the first list\'s second page asked for'); answer(2, { tools: [TOOLS[3]!, peekFrom('A', true)], _meta: { 'mcpl/rev': rev } }); await waitFor(() => notices(t.fw).slice(before).some((x) => x.includes('peek() — From list A.')), 'the first list applied whole, its second page included'); - assert.ok(listed().includes('arc--status'), 'its first page included too'); + assert.match(await look(t.fw, { server: 'arc', tool: 'status' }), /Status of the arcade\./, 'its first page included too'); assert.match(await look(t.fw, { server: 'arc', tool: 'peek' }), /From list A\./); } finally { await t.done(); } }); @@ -1257,7 +1261,8 @@ test('a hint or statement naming a tool the operator\'s tool policy leaves out s await roundTrip(t); t.state.statement.push({ name: 'lantern', visible: true }); await look(t.fw); - await settle(400); + await settle(400); // past the 200 ms recovery debounce + await roundTrip(t); // and the server has read whatever the host sent before it assert.ok(!t.server.received.slice(from).some((m) => m.method === 'tools/list')); assert.equal(t.server.received.slice(from).filter((m) => m.method === 'toolsets/list').length, 1, 'only look\'s own read'); } finally { await t.done(); } @@ -1342,7 +1347,8 @@ test('a paged tools/list whose rev only some pages carry isn\'t taken for one th t.server.peer.notify('notifications/tools/list_changed', {}); await waitFor(() => t.server.received.slice(from).filter((m) => m.method === 'tools/list').length >= 2, 'both pages asked for'); await roundTrip(t); - await settle(400); + await settle(400); // past the 200 ms recovery debounce + await roundTrip(t); // and the server has read whatever the host sent before it assert.ok(!t.server.received.slice(from).some((m) => m.method === 'toolsets/list'), 'no recovery'); } finally { await t.done(); } }); @@ -1355,7 +1361,7 @@ test('a push whose change no notice could carry keeps it for the push\'s own pat read({ kind: 'push', params }); await nextMacrotask(); writes = true; - assert.match(c.takeChangeText(params), /peek\(\)/); + assert.match(c.presence.render(c.takeChange(params)), /peek\(\)/); }); test('a recovery whose own tools/list straddled a change starts no second recovery', async () => { @@ -1379,7 +1385,8 @@ test('a recovery whose own tools/list straddled a change starts no second recove held.peer.reply(held.msg.id, { tools: TOOLS.slice(3), _meta: { 'mcpl/rev': t.state.rev } }); await waitFor(() => t.server.received.slice(from).some((m) => m.method === 'toolsets/list'), 'the recovery read toolsets/list'); await roundTrip(t); - await settle(400); + await settle(400); // past the 200 ms recovery debounce + await roundTrip(t); // and the server has read whatever the host sent before it assert.equal(t.server.received.slice(from).filter((m) => m.method === 'toolsets/list').length, 1); } finally { await t.done(); } }); @@ -1391,6 +1398,7 @@ test('look at a toolset whose options a server nests very deep still answers (in c.manifestChanged({ toolHints: true, toolsets: { maze: { title: 'Maze', options: deep } } }); const looked = await new ToolsetsModule(hub, () => null).handleToolCall({ id: 'l2', name: 'look', input: { server: 'arc', toolset: 'maze' } } as never); assert.ok(!looked.isError, String(looked.error)); + assert.match(resultText(looked), /options: \(nested too deeply to show\)/); }); test('look says when it couldn\'t ask a server, and shows what the host last knew of it', async () => { @@ -1965,3 +1973,83 @@ test('a tool a server describes malformed (a description that isn\'t a string, a } finally { await t.done(); } }); + +test('a first sentence that reached the model is left out of the next announcement of the same definition', async () => { + const t = await setup(); + const fresh = async (step: () => void, what: string) => { + const before = notices(t.fw).length; + step(); + await waitFor(() => notices(t.fw).length > before, what); + await roundTrip(t); + return notices(t.fw).slice(before).join('\n'); + }; + try { + assert.match(await fresh(() => t.server.peer.notify('tools/hints', t.state.hint([{ name: 'peek', visible: true }])), 'peek shown'), /peek\(\) — Peek around\./); + await fresh(() => t.server.peer.notify('tools/hints', t.state.hint([{ name: 'peek', visible: false }])), 'peek hidden'); + const again = await fresh(() => t.server.peer.notify('tools/hints', t.state.hint([{ name: 'peek', visible: true }])), 'peek shown again'); + assert.match(again, /peek\(\)/); + assert.doesNotMatch(again, /Peek around\./, 'its first sentence once'); + } finally { await t.done(); } +}); + +test('a look that restates the present counts as shown only what it showed: a tool a hint hid while look waited on another server keeps its first sentence owed', async () => { + const t = await setup(); + let holdFar = false; + let heldFar: (() => void) | null = null; + const far = await startScriptedServer({ + advert: ADVERT, + tools: [{ name: 'status', description: 'Status of the far arcade.', inputSchema: { type: 'object', properties: {} } }], + listRev: () => 1, + on: (msg, peer) => { + if (msg.method === 'toolsets/list') { + const answer = () => peer.reply(msg.id, { open: [], _meta: { 'mcpl/hints': { rev: 1, tools: [] } } }); + if (holdFar) { holdFar = false; heldFar = answer; } else answer(); + return; + } + if (msg.method === 'tools/call') { peer.reply(msg.id, { content: [{ type: 'text', text: 'done' }] }); return; } + if (msg.id !== undefined) peer.error(msg.id, -32601, 'Method not found'); + }, + }); + try { + await t.fw.framework.connectMcplServer({ id: 'far', toolPrefix: 'far', url: far.url, toolHints: true } as McplServerConfig); + await waitFor(() => far.received.some((m) => m.method === 'toolsets/list'), 'far connected and read'); + holdFar = true; + const lists = t.methods().filter((m) => m === 'toolsets/list').length; + t.state.statement = [{ name: 'peek', visible: true }]; + const looked = t.fw.framework.puppetToolCall('scout', 'toolsets--look', {}); + await waitFor(() => heldFar !== null && t.methods().filter((m) => m === 'toolsets/list').length > lists, 'look asked both servers; far holds its answer'); + await roundTrip(t); + t.server.peer.notify('tools/hints', t.state.hint([{ name: 'peek', visible: false }])); + await waitFor(() => notices(t.fw).some((x) => x.includes('now unavailable: peek')), 'the hint hid peek while look waited'); + heldFar!(); + assert.doesNotMatch(resultText((await looked).result), /peek\(\)/, 'look showed the present, with peek hidden'); + await roundTrip(t); + const before = notices(t.fw).length; + t.server.peer.notify('tools/hints', t.state.hint([{ name: 'peek', visible: true }])); + await waitFor(() => notices(t.fw).slice(before).some((x) => x.includes('peek()')), 'peek shown'); + assert.match(notices(t.fw).slice(before).join('\n'), /peek\(\) — Peek around\./, 'its first sentence, never yet shown'); + } finally { await t.done(); await far.close(); } +}); + +test('a tool whose schema holds a literal nested very deep, or whose description or class is an object, is announced and looked at', async () => { + const { t, holding, held } = await holdingServer(); + try { + holding.add('tools/list'); + const before = notices(t.fw).length; + t.server.peer.notify('notifications/tools/list_changed', {}); + await waitFor(() => held.length === 1, 'the tool list asked for'); + holding.clear(); + const listed = TOOLS.filter((x) => x.name !== 'peek').map((x) => JSON.stringify(x)).join(','); + const dynamic = '"_meta":{"mcpl/presence":"dynamic","mcpl/visible":true}'; + const deep = `${'{"a":'.repeat(20000)}1${'}'.repeat(20000)}`; + rawLine(t, `{"jsonrpc":"2.0","id":${JSON.stringify(held[0]!.id)},"result":{"tools":[${listed},` + + `{"name":"peek","description":{"toString":1},"inputSchema":{"type":"object","properties":{}},"_meta":{"mcpl/presence":"dynamic","mcpl/visible":true,"mcpl/class":[{"toString":1}]}},` + + `{"name":"fixed","description":"Fixed.","inputSchema":{"type":"object","properties":{"a":{"const":${deep}},"b":{"enum":[${deep}]},"c":{"type":"string","default":${deep}}}},${dynamic}}],` + + `"_meta":{"mcpl/rev":${t.state.rev}}}}`); + await waitFor(() => notices(t.fw).slice(before).some((x) => x.includes('fixed(')), 'the tools announced'); + assert.match(notices(t.fw).slice(before).join('\n'), /fixed\(a\?: …, b\?: …, c\?: string =…\) — Fixed\./); + const peek = await look(t.fw, { server: 'arc', tool: 'peek' }); + assert.match(peek, /^peek\(\)\ninputSchema:/, 'no description and no class line where they aren\'t text'); + assert.match(await look(t.fw, { server: 'arc', tool: 'fixed' }), /^fixed\(a\?: …, b\?: …, c\?: string =…\)\nFixed\./); + } finally { await t.done(); } +}); From 856849255277f9ffc81c9b751bd633c4ee3aacd4 Mon Sep 17 00:00:00 2001 From: Prudence-2217 Date: Mon, 26 Oct 2026 02:28:06 -0400 Subject: [PATCH 26/46] docs(mcpl): a move's note is a notice, a list can undo a move a rebuild publishes, and the order claim says what the window can still show - README and the changed fragment: a move reaches the tool list at the next rebuild that lists the server, and once it has published, a notice says where the tool went, so the note follows the grant like any notice. A list read while a rebuild is under way can undo a move that rebuild then publishes; neither is told, and the next rebuild puts the tool back. - README and the fixed fragment: changes are told in the order they were read; the agent's window can still show a call's result, a push's event, or a change its taker couldn't show after a change read later. Co-Authored-By: Claude Opus 5.5 --- README.md | 16 ++++++++++------ changelog.d/mcpl-toolsets-hint-state.changed.md | 2 +- changelog.d/mcpl-toolsets-hint-state.fixed.md | 2 +- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 2e7d16c26..2fc133933 100644 --- a/README.md +++ b/README.md @@ -465,11 +465,13 @@ framework registers a `toolsets` module with four constant tools: as their message is read, in `rev` order, whatever becomes of the message (a refused push, a result that came after its timeout). A move between static and dynamic takes effect when the server's `tools/list` states it, and the tool -list follows at its next rebuild, whichever refresh brings it (a move is no -reason to rebuild the tool list early). Until then the tool is called at either -place, where the tool list shows it or where the server's -latest list put it (RFC-014 §4.2: it keeps working where it was), and the agent -is told where it went once that rebuild has published the list. +list follows at its next rebuild that lists the server, whichever refresh +brings it (a move is no reason to rebuild the tool list early). Until then the +tool is called at either place, where the tool list shows it or where the +server's latest list put it (RFC-014 §4.2: it keeps working where it was), and +once that rebuild has published the list, a notice says where it went. A list +read while a rebuild is under way can undo a move that rebuild then publishes; +neither is told, and the next rebuild puts the tool back. A skipped rev re-reads `tools/list` and then `toolsets/list`, which the host also reads after every (re)connect, adopting what the server kept open and never re-opening anything. @@ -483,7 +485,9 @@ already gone as a notice (below). A dynamic tool's change also goes as a notice when it wouldn't fit whole within its result's inline cap, or when its call fails after the result was read. A call's change also goes as a notice, ahead of its result, when a later change goes to the agent before the call's caller has -taken it, so changes reach the agent in the order they were read. Notices and reminders +taken it, so changes are told in the order they were read (the agent's window +can still show a call's result, a push's event, or a change its taker couldn't +show after a change read later). Notices and reminders need the server's `pushEvents` grant, like any delivery; without it, a change that would go as a notice is dropped, and `toolsets--look` shows the state. A new tool's first sentence is told the first time its definition appears. Suggestions are shown, never acted on. Each diff --git a/changelog.d/mcpl-toolsets-hint-state.changed.md b/changelog.d/mcpl-toolsets-hint-state.changed.md index fb8231b27..bfec2c708 100644 --- a/changelog.d/mcpl-toolsets-hint-state.changed.md +++ b/changelog.d/mcpl-toolsets-hint-state.changed.md @@ -1,4 +1,4 @@ - MCPL RFC-014 tool presence (`toolHints: true`): - - A move between static and dynamic takes effect when the server's `tools/list` states it, not when a hint announces it, and the agent is told where the tool went once the next rebuild of the model's tool list has published it. A move is no reason to rebuild the tool list early, whether a hint announced it or the host's own re-read found it. + - A move between static and dynamic takes effect when the server's `tools/list` states it, not when a hint announces it, and once the next rebuild of the model's tool list that lists the server has published it, a notice says where the tool went. A move is no reason to rebuild the tool list early, whether a hint announced it or the host's own re-read found it. - Each `ended` id is told once while the host runs: told ids are kept in memory, an id counts as told only once a notice or a result carrying it reached the agent, and it is forgotten once the server stops reporting it. Rare interleavings, such as a `toolsets--look` overlapping another read of the same server, can tell one twice. - `toolsets--look` naming a tool shows its signature, description and input schema, as before; the README no longer calls that its whole definition. What look's own re-read of the server changed is in its answer when it restates everything, and otherwise told as a notice. diff --git a/changelog.d/mcpl-toolsets-hint-state.fixed.md b/changelog.d/mcpl-toolsets-hint-state.fixed.md index bff44b84b..c90e8e398 100644 --- a/changelog.d/mcpl-toolsets-hint-state.fixed.md +++ b/changelog.d/mcpl-toolsets-hint-state.fixed.md @@ -1,6 +1,6 @@ - MCPL RFC-014 tool presence (`toolHints: true`), fixes to how hint changes are applied and told: - A `tools/list` is applied whole as its last page is read, so a hint read just after it isn't undone, and a malformed or timed-out list changes nothing. A paged list whose pages straddle a change, or a hint or `toolsets/list` entry naming a tool `tools/list` hasn't listed, is recovered from. - - Changes reach the agent in the order they were read, each once: a result's change nobody shows with it is told as a notice before any later change, and a new tool's first sentence counts as shown only once text describing it reached the model. + - Changes are told in the order they were read, each once: a result's change nobody shows with it is told as a notice before any later change, and a new tool's first sentence counts as shown only once text describing it reached the model. - A change that would go as a notice to a server without the `pushEvents` grant is dropped, and `toolsets--look` shows the state. - A tool the operator's tool policy refuses never starts a recovery, and a tool listed twice is known by its first listing. - Rendering a server's definitions never throws: a description that isn't a string is left out, and a schema or a toolset's `options` nested very deep is cut short, rather than failing a notice or `toolsets--look`. From 4d2a3c516546005e51c2dac108605bcf68f3772b Mon Sep 17 00:00:00 2001 From: Octavia-2412 Date: Mon, 26 Oct 2026 02:38:12 -0400 Subject: [PATCH 27/46] fix(mcpl): look shows the definition act checks, a broken-off listing tells no moves, and a passed-on call's change fits the store's cap - look naming a tool keyed it by where the tool is visible, else the toolset's definition. act, given no instance, takes the only one open and checks that instance's own definition, so for a tool hidden in the only open instance, look showed one definition and act checked another. look now keys the tool as act does: the instance named, else the only one open. - The rebuild recorded a server's moves before it went through that server's listing, so a listing that broke off partway (an entry the loop can't read, such as null, which the read tap skips) still had its moves told at publication, where the tool list doesn't show them. The moves are recorded once the listing is through, and stay pending otherwise. - withWholeChange measured a joined change against the call's own cap, and with none it measured nothing, while the store holds the result to the agent's cap. A module that passes the model's call on to executeToolCall by its id, as a call built afresh, reached that: the model's result was no longer the server's content alone. The measure now uses the cap the store applies (resultMaxChars, else the agent's). Tests, each failing on its assertion at the commit before this one: - look naming a tool hidden in the only open instance, whose own definition the statement restates, shows that definition, the one act checks; - a rebuild whose listing of a server holds a null entry before a moved tool tells none of that server's moves, and the tool list doesn't show the tool; - a module that passes the model's call on to toolsets--act, under a 1000-character cap, keeps the 990-character result whole, and the change goes as a notice. Each fails again with its fix taken out. Co-Authored-By: Claude Opus 5.5 --- src/framework.ts | 10 +++++---- src/mcpl/toolsets-module.ts | 4 ++-- test/mcpl-tool-hints.test.ts | 36 +++++++++++++++++++++++++++++++ test/mcpl-toolsets-wiring.test.ts | 31 +++++++++++++++++++++++++- 4 files changed, 74 insertions(+), 7 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index 09b0bffe9..0b1d62d14 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -22470,8 +22470,9 @@ export class AgentFramework { private withWholeChange(agentName: string, call: ToolCall, result: ToolResult, change: string, maxChars: number | undefined): ToolResult | null { const joined = withChangeText(result, change); - const { text } = this.storedToolResultText(agentName, { id: call.id, name: call.name, input: call.input, result: joined, durationMs: 0 }, maxChars); - return text.endsWith(change.toWellFormed()) && (maxChars === undefined || text.length <= maxChars) ? joined : null; + const cap = maxChars ?? this.resultMaxCharsFor(agentName, call.id); + const { text } = this.storedToolResultText(agentName, { id: call.id, name: call.name, input: call.input, result: joined, durationMs: 0 }, cap); + return text.endsWith(change.toWellFormed()) && text.length <= cap ? joined : null; } private withActStateHint(call: ToolCall, serverId: string, error: unknown, text: string): string { @@ -29794,9 +29795,9 @@ export class AgentFramework { // The static↔dynamic moves the lists made since the last rebuild // reach the tool list here (§4.2). The agent is told where each tool // went once this refresh publishes the list, and not by a refresh that - // doesn't: the moves stay pending for one that does. + // doesn't, or whose listing of the server breaks off: the moves stay + // pending for one that does. const presence = this.toolsetHub?.get(server.id); - if (presence?.presence.active) movesListed.set(server.id, { controller: presence, moves: presence.presence.moves() }); const dynamic = (name: string): boolean => !!presence?.presence.active && presence.presence.presenceOf(name) === 'dynamic'; for (const tool of result.tools) { if (!isToolAllowed(tool.name, config)) continue; @@ -29835,6 +29836,7 @@ export class AgentFramework { } } if (repeated.length > 0) repeats.push({ serverId: server.id, tools: repeated }); + if (presence?.presence.active) movesListed.set(server.id, { controller: presence, moves: presence.presence.moves() }); } catch { // Server may not support tools/list — skip silently. A live server // whose handshake advertised no MCP `tools` has, in effect, listed diff --git a/src/mcpl/toolsets-module.ts b/src/mcpl/toolsets-module.ts index bc0b73e6a..8a0542c37 100644 --- a/src/mcpl/toolsets-module.ts +++ b/src/mcpl/toolsets-module.ts @@ -482,9 +482,9 @@ export class ToolsetsModule implements Module { const p = c.presence; if (!p.tool(tool)) fail(`${c.serverId} has no tool "${tool}": ${META_TOOL_NAMES.look} {server:"${c.serverId}"} shows what's available`); const ts = p.toolsetOf(tool); - const where = [...p.view().entries()].filter(([, e]) => e.tools.has(tool)).map(([k]) => k); // The definition act would check, wherever act would take the tool. - const key = !p.shownAs(tool, 'dynamic') ? undefined : instance && ts ? keyOf(ts, instance) : where[0] ?? actKey(p, tool, undefined); + const open = !instance && hasInstances(p, ts) ? p.openRecords(ts) : []; + const key = !p.shownAs(tool, 'dynamic') ? undefined : actKey(p, tool, instance ?? (open.length === 1 ? open[0]!.instance : undefined)); const def = key ? p.defOf(key, tool) : p.tool(tool)!; return textResult(`${renderToolDefinition(def)}\n${p.toolStatus(tool)}`); } diff --git a/test/mcpl-tool-hints.test.ts b/test/mcpl-tool-hints.test.ts index 6f97ec8d6..9d26f2fd9 100644 --- a/test/mcpl-tool-hints.test.ts +++ b/test/mcpl-tool-hints.test.ts @@ -1492,6 +1492,42 @@ async function movingServer(moved: Json[]) { return { t, moveAndHold, release: () => release?.() }; } +test('look naming a tool with no instance shows the definition act checks: the sole open instance\'s own', async () => { + const own = { name: 'play', instance: 'g1', visible: false, definition: { name: 'play', description: 'Play one of the legal points.', inputSchema: { type: 'object', properties: { point: { enum: ['C3'] } }, required: ['point'] } } }; + const t = await setup((msg, peer, state) => { + if (msg.method !== 'toolsets/open') return false; + state.open.push({ toolset: 'game', instance: 'g1', title: 'Game g1' }); + const hints = state.hint([own]); + // The statement restates the definition (RFC §6.1), so look's re-read keeps it. + state.statement = [own]; + peer.reply(msg.id, { toolset: 'game', instance: 'g1', title: 'Game g1', _meta: { 'mcpl/hints': hints } }); + return true; + }); + try { + await callTool(t.fw.framework, 'toolsets--open', { server: 'arc', toolset: 'game', instance: 'g1' }); + const refused = await callTool(t.fw.framework, 'toolsets--act', { server: 'arc', tool: 'play', args: { point: 'D4' } }); + assert.match(resultText(refused), /must be one of "C3"/, 'act checks g1\'s own definition'); + assert.match(await look(t.fw, { server: 'arc', tool: 'play' }), /Play one of the legal points\./, 'look shows the same one'); + } finally { await t.done(); } +}); + +test('RFC-014 §4.2: a rebuild whose listing of a server breaks off tells none of that server\'s moves, and the tool list doesn\'t show them', async () => { + const moved = withPresence('peek', 'static'); + const { t, moveAndHold, release } = await movingServer([moved[0]!, null as unknown as Json, ...moved.slice(1)]); + try { + await moveAndHold(); + release(); + // A rebuild that changes nothing a caller sees has no public sign of its end: this waits on the framework's own flag. + await waitFor(() => !(t.fw.framework as unknown as { mcplToolRefreshInFlight: boolean }).mcplToolRefreshInFlight, 'the rebuild ended'); + await nextMacrotask(); + assert.ok(!t.fw.framework.getAllTools().some((x) => x.name === 'arc--peek'), 'the tool list doesn\'t show peek'); + assert.equal(notices(t.fw).filter((x) => x.includes('peek is in your tool list now')).length, 0, 'so the move isn\'t told'); + } finally { + release(); + await t.done(); + } +}); + test('RFC-014 §4.2: a tool a list moves to static is called both ways until the tool list shows it, then by its listed name', async () => { const { t, moveAndHold, release } = await movingServer(withPresence('peek', 'static')); try { diff --git a/test/mcpl-toolsets-wiring.test.ts b/test/mcpl-toolsets-wiring.test.ts index c5679a62d..5004d72c8 100644 --- a/test/mcpl-toolsets-wiring.test.ts +++ b/test/mcpl-toolsets-wiring.test.ts @@ -1,7 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import type { Module } from '../src/types/index.js'; +import type { Module, ToolCall, ToolResult, EventResponse, ToolDefinition } from '../src/types/index.js'; import type { McplServerConfig } from '../src/mcpl/types.js'; import { startScriptedServer, makeFramework, waitFor, callTool, resultText, windowText, type Json } from './helpers/toolset-servers.js'; import { createMockResponse } from './helpers/mock-membrane.js'; @@ -270,6 +270,35 @@ test('a model\'s call whose result, with its tool change after it, would pass th } }); +test('a module that passes the model\'s call on to toolsets--act keeps its result whole under the agent\'s cap, and the change goes as a notice', async () => { + const { server } = await hintingServer({ content: [{ type: 'text', text: 'x'.repeat(990) }] }); + class Relay implements Module { + readonly name = 'relay'; + framework: { executeToolCall(call: never): Promise } | null = null; + async start(): Promise {} + async stop(): Promise {} + getTools(): ToolDefinition[] { return [{ name: 'peek_for_me', description: 'Peek, through act.', inputSchema: { type: 'object', properties: {} } } as ToolDefinition]; } + // Passed on by id, as a call built afresh: no resultMaxChars. + async handleToolCall(call: ToolCall): Promise { + return this.framework!.executeToolCall({ id: call.id, name: 'toolsets--act', input: { server: 'arc', tool: 'peek', args: {} }, callerAgentName: call.callerAgentName } as never); + } + async onProcess(event: { type: string }): Promise { + return event.type === 'external-message' ? { addMessages: [{ participant: 'User', content: [{ type: 'text', text: 'go' }] }], requestInference: true } : {}; + } + } + const relay = new Relay(); + const fw = await connectedTo(server, { toolResultInlineMaxChars: 1000, modules: [relay] }); + relay.framework = fw.framework as never; + try { + await modelCalls(fw, [{ type: 'tool_use', id: 'toolu_relay', name: 'relay--peek_for_me', input: {} }]); + assert.equal(resultFor(fw, 'toolu_relay'), 'x'.repeat(990), 'the result whole, without the change'); + await waitFor(() => notices(fw).some((x) => x.includes('peek()')), 'the change told as a notice'); + } finally { + await fw.stop(); + await server.close(); + } +}); + test('a model\'s call whose tool change fits goes with its result even where a definition holds a lone surrogate', async () => { const { server } = await hintingServer({ content: [{ type: 'text', text: 'all quiet' }] }, { peek: { ...PEEK, description: 'Peek \uD800 around.' } }); const fw = await connectedTo(server); From 2d2fc8c28519ba162c208b5fa1884cad9d3d2b7c Mon Sep 17 00:00:00 2001 From: Octavia-2412 Date: Mon, 26 Oct 2026 02:38:25 -0400 Subject: [PATCH 28/46] test(mcpl): act's results to a script are compared with a direct call's "act's results arrive as a direct call's do" asserted only the script's structuredContent, which a script reads the same whether act reads the server's result or the model's view of it. The server now answers with two text blocks beside its structured content, and the script reads both an act of the dynamic tool and a direct call of a static tool answered the same way: their content and structured content match, with each of the server's blocks. Having a script's act read the model's view instead now fails it; it passed before. Co-Authored-By: Claude Opus 5.5 --- test/mcpl-toolsets-wiring.test.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/test/mcpl-toolsets-wiring.test.ts b/test/mcpl-toolsets-wiring.test.ts index 5004d72c8..3958de0f3 100644 --- a/test/mcpl-toolsets-wiring.test.ts +++ b/test/mcpl-toolsets-wiring.test.ts @@ -605,7 +605,7 @@ test('act\'s results arrive as a direct call\'s do: structured content to a scri const failing = (msg.params as Json).arguments && ((msg.params as Json).arguments as Json).fail === true; peer.reply(msg.id, failing ? { content: [{ type: 'text', text: 'the board is locked' }], isError: true } - : { content: [{ type: 'text', text: 'peeked' }], structuredContent: { seen: ['a', 'b'] } }); + : { content: [{ type: 'text', text: 'peeked' }, { type: 'text', text: 'twice' }], structuredContent: { seen: ['a', 'b'] } }); return; } if (msg.id !== undefined) peer.error(msg.id, -32601, 'Method not found'); @@ -613,12 +613,16 @@ test('act\'s results arrive as a direct call\'s do: structured content to a scri }); const fw = await connectedTo(server, { codeExecution: { enabled: true } }); try { - const code = 'const r = await tools["toolsets--act"]({ server: "arc", tool: "peek", args: {} }); text(JSON.stringify(r.structuredContent));'; + const code = 'const a = await tools["toolsets--act"]({ server: "arc", tool: "peek", args: {} }); const d = await tools["arc--status"]({}); text(JSON.stringify([a.content, a.structuredContent, d.content, d.structuredContent]));'; await modelCalls(fw, [ { type: 'tool_use', id: 'toolu_script', name: 'code_execution', input: { code } }, { type: 'tool_use', id: 'toolu_error', name: 'toolsets--act', input: { server: 'arc', tool: 'peek', args: { fail: true } } }, ]); - assert.match(resultFor(fw, 'toolu_script'), /\{"seen":\["a","b"\]\}/); + const printed = resultFor(fw, 'toolu_script'); + const [actContent, actStructured, directContent, directStructured] = JSON.parse(printed.slice(printed.indexOf('[['), printed.lastIndexOf(']') + 1)) as unknown[]; + assert.deepEqual(actStructured, { seen: ['a', 'b'] }); + assert.deepEqual([actContent, actStructured], [directContent, directStructured], 'as the static tool\'s direct call reads the same answer'); + assert.equal((actContent as unknown[]).length, 2, 'each of the server\'s blocks'); assert.match(resultFor(fw, 'toolu_error'), /the board is locked/); const results = (fw.membrane.lastStream?.receivedToolResults ?? []).flat() as Array<{ toolUseId: string; isError?: boolean }>; assert.equal(results.find((r) => r.toolUseId === 'toolu_error')?.isError, true, 'an error result'); From f4923ac426031bb53a398faafb0c4493fe3017b4 Mon Sep 17 00:00:00 2001 From: Octavia-2412 Date: Mon, 26 Oct 2026 02:38:51 -0400 Subject: [PATCH 29/46] docs(mcpl): the wiring's texts say what null selectors, structured-only results, refused acts and the either-place rule do MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The wiring fragment says a selector that is present but isn't a name is refused; null reads as left out (a test pins it), and it now says so. It also says that a result that is structured content alone shows that content, as its JSON, before the change that goes with it, and that a refused programmatic toolsets--act is traced as tool:failed, as the model's own refused act already was, and that a change that can't be told for want of an agent is logged, as one without the pushEvents grant already was. - resolveAct's doc said it refuses a static tool; it refuses a tool the model hasn't been shown as dynamic (RFC-014 §4.2). resolveMcplTool's doc said dispatch refuses a dynamic tool by its listed name; it does unless the published list or the latest announcement shows it as static. Co-Authored-By: Claude Opus 5.5 --- changelog.d/mcpl-toolsets-wiring.fixed.md | 5 +++-- src/framework.ts | 2 +- src/mcpl/toolsets-module.ts | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/changelog.d/mcpl-toolsets-wiring.fixed.md b/changelog.d/mcpl-toolsets-wiring.fixed.md index f6932bbe1..b595101b1 100644 --- a/changelog.d/mcpl-toolsets-wiring.fixed.md +++ b/changelog.d/mcpl-toolsets-wiring.fixed.md @@ -1,6 +1,7 @@ - MCPL RFC-014 tool presence (`toolHints: true`), fixes to how the toolsets host is wired into the framework: - - A result's tool change goes with it only where the result reaches an agent: the model's own call, and the operator's puppet, whose call and result are stored in the agent's window. A script's, a module's or a host's own call leaves it to a notice. A dynamic tool's change that wouldn't fit whole within its result's inline cap, or whose call fails after its result was read, goes as a notice too. - - An open or close whose outcome is unknown is reported as unconfirmed rather than as not done, and names `toolsets--look`, which says when it couldn't ask the server. A selector that is present but isn't a name is refused, and a refusal for a name the server doesn't have says what it has. + - A result's tool change goes with it only where the result reaches an agent: the model's own call, and the operator's puppet, whose call and result are stored in the agent's window. A script's, a module's or a host's own call leaves it to a notice. A dynamic tool's change that wouldn't fit whole within its result's inline cap, or whose call fails after its result was read, goes as a notice too. A result that is structured content alone shows that content, as its JSON, before the change that goes with it. + - An open or close whose outcome is unknown is reported as unconfirmed rather than as not done, and names `toolsets--look`, which says when it couldn't ask the server. A selector that is present but isn't a name is refused (null reads as left out), and a refusal for a name the server doesn't have says what it has. - Removing a server, or restarting it under another id or with `toolHints` off, leaves no toolset state behind. A stop or a disconnect while a `toolHints` server is still connecting ends the connect. - The capability mask no longer reports `toolHints`, `toolsets` and `surface` as masked: `toolHints: false` turns hints off. Operator tool-name patterns naming a dynamic tool count as matched. - Server-chosen values in the toolset host's log lines, and in the trace of a `toolsets--act` that can't be resolved, are redacted with the servers' configuration. A request kept for a late answer no longer holds a call's arguments. + - A programmatic `toolsets--act` that's refused is traced as `tool:failed`, as the model's own refused act already was. A tool change that can't be told for want of an agent is logged, as one without the `pushEvents` grant already was. diff --git a/src/framework.ts b/src/framework.ts index 0b1d62d14..e9a6db48d 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -23939,7 +23939,7 @@ export class AgentFramework { * Find the MCPL server for a tool: [serverId, prefix], or null. * The tool listing decides: the server whose tools/list listed the name * (offered to the model, or an RFC-014 dynamic tool, which dispatch then - * refuses by that name), after its tool policy, as of the last tool refresh (a server that + * refuses by that name unless the published list or the latest announcement shows it as static), after its tool policy, as of the last tool refresh (a server that * closed since keeps its names until the next one). A name the listing * doesn't hold (invented by the model, or no longer listed) goes to the * longest matching prefix. Prefixes that meet, such as `foo` and diff --git a/src/mcpl/toolsets-module.ts b/src/mcpl/toolsets-module.ts index 8a0542c37..b01db3068 100644 --- a/src/mcpl/toolsets-module.ts +++ b/src/mcpl/toolsets-module.ts @@ -205,7 +205,7 @@ export type ActTarget = { ok: true; serverId: string; tool: string; instance?: s * or the reason it can't be sent. The framework then dispatches it exactly as * a direct call of that tool, with `_meta["mcpl/instance"]` added (RFC §5.5). * Refused here, before anything is sent: a tool no presenting server has, a - * static tool (it's in the tool list), an instance that isn't named where + * tool the model hasn't been shown as dynamic (RFC §4.2), an instance that isn't named where * several are open, a group the host has closed (RFC §5.4), and arguments * that don't fit the definition in force for that instance (RFC §6.1). */ From af2db14bb37d7b4e832b66542964ba2f9dbea8d2 Mon Sep 17 00:00:00 2001 From: Prudence-2217 Date: Mon, 26 Oct 2026 03:14:14 -0400 Subject: [PATCH 30/46] fix(mcpl): a restating look's taken change keeps its blocks for a notice, schema types read only as text, and four tests say true reasons From the review of the previous round's fixes: - A look that restates the present now hands its taker each change whole, marked blocksShown: false. told() then counts none of its definitions shown, since look showed the live catalog, while a notice that tells it (the puppet's fallback when its pair can't be stored) still renders and counts them. The previous round emptied the change's blocks instead, so that fallback told nothing: a puppet look whose pair the store refused lost the change it found. - typeSig reads a schema's type only from text. A type, or an entry of a type list, that isn't a string reads as no type, where an object whose toString isn't callable threw TypeError from rendering. - The changed fragment says a move's notice doesn't come when a list read during the rebuild undid the move. Tests: - a puppet look that restates the present, whose pair can't be stored, tells the change it found as a notice (fails when the look hands over the change emptied of its blocks); - the malformed-definition test also gives types that aren't text, and a class list mixing text with an object, and checks look keeps the text entry; - a definition first described in a notice that wasn't written is described again once the server is granted pushEvents, now through a manifest change mid-connection rather than in process, since a server can produce it (fails when a refused notice counts its definitions shown); - the deep-options look test, still in process, names its true reason: the suite's scripted server writes its initialize answer with JSON.stringify, which can't write options that deep; - the paged-lists test gives list A's first page its own status definition, so look's answer shows that page reached the controller, and checks the model's tool list holds the tool too. By run (Linux, Node 24.22.1): typecheck clean; the three toolset files pass 132 of 132. Co-Authored-By: Claude Opus 5.5 --- .../mcpl-toolsets-hint-state.changed.md | 2 +- src/mcpl/tool-presence.ts | 2 + src/mcpl/tool-signature.ts | 4 +- src/mcpl/toolset-host.ts | 5 +- src/mcpl/toolsets-module.ts | 4 +- test/mcpl-tool-hints.test.ts | 70 ++++++++++++++----- 6 files changed, 63 insertions(+), 24 deletions(-) diff --git a/changelog.d/mcpl-toolsets-hint-state.changed.md b/changelog.d/mcpl-toolsets-hint-state.changed.md index bfec2c708..805328a9c 100644 --- a/changelog.d/mcpl-toolsets-hint-state.changed.md +++ b/changelog.d/mcpl-toolsets-hint-state.changed.md @@ -1,4 +1,4 @@ - MCPL RFC-014 tool presence (`toolHints: true`): - - A move between static and dynamic takes effect when the server's `tools/list` states it, not when a hint announces it, and once the next rebuild of the model's tool list that lists the server has published it, a notice says where the tool went. A move is no reason to rebuild the tool list early, whether a hint announced it or the host's own re-read found it. + - A move between static and dynamic takes effect when the server's `tools/list` states it, not when a hint announces it, and once the next rebuild of the model's tool list that lists the server has published it, a notice says where the tool went, unless a list read during that rebuild undid the move. A move is no reason to rebuild the tool list early, whether a hint announced it or the host's own re-read found it. - Each `ended` id is told once while the host runs: told ids are kept in memory, an id counts as told only once a notice or a result carrying it reached the agent, and it is forgotten once the server stops reporting it. Rare interleavings, such as a `toolsets--look` overlapping another read of the same server, can tell one twice. - `toolsets--look` naming a tool shows its signature, description and input schema, as before; the README no longer calls that its whole definition. What look's own re-read of the server changed is in its answer when it restates everything, and otherwise told as a notice. diff --git a/src/mcpl/tool-presence.ts b/src/mcpl/tool-presence.ts index 0c901a388..bb39b0707 100644 --- a/src/mcpl/tool-presence.ts +++ b/src/mcpl/tool-presence.ts @@ -75,6 +75,8 @@ export interface ChangeSet { notes: string[]; /** Ended records' notes, by id: rendered while the id is untold, told once the set reaches an agent (markTold). */ ended?: Array<{ id: string; note: string }>; + /** false when its taker showed the change without its blocks (a look that restates the present shows the live catalog instead): told() then counts none of its definitions shown, while a notice that tells it still shows them. */ + blocksShown?: false; } export const emptyChange = (): ChangeSet => ({ changes: [], suggest: [], notes: [] }); diff --git a/src/mcpl/tool-signature.ts b/src/mcpl/tool-signature.ts index f26a9540b..c1cdd2f27 100644 --- a/src/mcpl/tool-signature.ts +++ b/src/mcpl/tool-signature.ts @@ -51,7 +51,9 @@ function typeSig(schema: JsonSchema | undefined, depth = 0): string { if (schema.const !== undefined) return literal(schema.const); const alts = (schema.oneOf ?? schema.anyOf) as JsonSchema[] | undefined; if (Array.isArray(alts)) return alts.map((s) => typeSig(s, depth + 1)).join(' | '); - const t = Array.isArray(schema.type) ? (schema.type as string[]).join('|') : (schema.type as string | undefined); + // Only text names a type; anything else a server sends there reads as no type. + const types = (Array.isArray(schema.type) ? schema.type : [schema.type]).filter((x): x is string => typeof x === 'string'); + const t = types.length ? types.join('|') : undefined; switch (t) { case 'integer': case 'number': { diff --git a/src/mcpl/toolset-host.ts b/src/mcpl/toolset-host.ts index 7a310c5bf..d0f55d3be 100644 --- a/src/mcpl/toolset-host.ts +++ b/src/mcpl/toolset-host.ts @@ -366,7 +366,8 @@ export class ToolsetController { const text = this.presence.render(cs, { mark: false }); if (!text) return 'empty'; if (!this.deps.notify(this.serverId, text, { invite: cs.suggest.some((s) => s.action === 'open') })) return 'refused'; - this.told(cs); + this.presence.markTold(cs.ended); + this.presence.markShown(cs); return 'told'; } @@ -428,7 +429,7 @@ export class ToolsetController { /** A change its taker showed to an agent: its ended records count as told, and the definitions it describes as shown. */ told(cs: ChangeSet): void { this.presence.markTold(cs.ended); - this.presence.markShown(cs); + if (cs.blocksShown !== false) this.presence.markShown(cs); } /** A change its taker couldn't show, told as a notice instead, or dropped when none can be written. */ diff --git a/src/mcpl/toolsets-module.ts b/src/mcpl/toolsets-module.ts index b01db3068..319164933 100644 --- a/src/mcpl/toolsets-module.ts +++ b/src/mcpl/toolsets-module.ts @@ -415,8 +415,8 @@ export class ToolsetsModule implements Module { const lines = restates ? read.flatMap(({ c, cs }) => { const said = [...cs.suggest.map((s) => c.presence.renderSuggestion(s)), ...c.presence.untoldNotes(cs), ...cs.notes]; - // The catalog above counted what it showed; the change's own blocks weren't shown. - take?.({ toolsets: c, cs: { ...cs, changes: [] } }); + // The catalog above counted what it showed; the change's own blocks weren't shown, but stay for a notice if its taker can't show the result. + take?.({ toolsets: c, cs: { ...cs, blocksShown: false } }); return said; }) : []; diff --git a/test/mcpl-tool-hints.test.ts b/test/mcpl-tool-hints.test.ts index 9d26f2fd9..dfb0eadbc 100644 --- a/test/mcpl-tool-hints.test.ts +++ b/test/mcpl-tool-hints.test.ts @@ -1184,12 +1184,13 @@ test('two paged tools/lists read at once each apply whole, never mixed', async ( const answer = (i: number, result: Json) => pages[i]!.peer.reply(pages[i]!.msg.id, result); const rev = t.state.rev; const peekFrom = (list: string, visible: boolean) => ({ name: 'peek', description: `From list ${list}.`, inputSchema: { type: 'object', properties: {} }, _meta: { 'mcpl/presence': 'dynamic', 'mcpl/visible': visible } }); - answer(0, { tools: TOOLS.slice(0, 3), nextCursor: 'a2', _meta: { 'mcpl/rev': rev } }); + answer(0, { tools: [{ ...TOOLS[0]!, description: 'Status, from list A.' }, ...TOOLS.slice(1, 3)], nextCursor: 'a2', _meta: { 'mcpl/rev': rev } }); answer(1, { tools: [...TOOLS.slice(0, 4), peekFrom('B', false)], _meta: { 'mcpl/rev': rev } }); await waitFor(() => pages.length >= 3, 'the first list\'s second page asked for'); answer(2, { tools: [TOOLS[3]!, peekFrom('A', true)], _meta: { 'mcpl/rev': rev } }); await waitFor(() => notices(t.fw).slice(before).some((x) => x.includes('peek() — From list A.')), 'the first list applied whole, its second page included'); - assert.match(await look(t.fw, { server: 'arc', tool: 'status' }), /Status of the arcade\./, 'its first page included too'); + assert.match(await look(t.fw, { server: 'arc', tool: 'status' }), /Status, from list A\./, 'its first page included too'); + assert.ok(t.fw.framework.getAllTools().some((x) => x.name === 'arc--status'), 'and the model\'s tool list holds it'); assert.match(await look(t.fw, { server: 'arc', tool: 'peek' }), /From list A\./); } finally { await t.done(); } }); @@ -1282,15 +1283,28 @@ test('look with a tool or toolset, or one that fails, tells its re-read\'s chang } finally { await t.done(); } }); -test('a definition first described in a notice that wasn\'t written is described again (in process: a server\'s grant doesn\'t change while it\'s connected, so a refused notice can\'t be followed by a written one)', () => { - let writing = false; - const { told, read } = tappedController(() => writing); - read({ kind: 'hints', params: { rev: 1, tools: [{ name: 'peek', visible: true }] } }); - read({ kind: 'hints', params: { rev: 2, tools: [{ name: 'peek', visible: false }] } }); - writing = true; - read({ kind: 'hints', params: { rev: 3, tools: [{ name: 'peek', visible: true }] } }); - assert.equal(told.length, 1); - assert.match(told[0]!, /peek\(\) — Peek around\./); +test('a definition first described in a notice that wasn\'t written is described again once the server is granted pushEvents', async () => { + let advert: Json = { ...ADVERT, pushEvents: false }; + const t = await setup((msg, peer) => { + if (msg.method === 'mcpl/manifest') { peer.reply(msg.id, { version: '0.5', ...advert }); return true; } + return false; + }, {}, advert); + const count = (method: string) => t.methods().filter((m) => m === method).length; + try { + const before = notices(t.fw).length; + t.server.peer.notify('tools/hints', t.state.hint([{ name: 'peek', visible: true }])); + t.server.peer.notify('tools/hints', t.state.hint([{ name: 'peek', visible: false }])); + await roundTrip(t); + assert.equal(notices(t.fw).length, before, 'no notice without the grant'); + const [manifests, lists] = [count('mcpl/manifest'), count('tools/list')]; + advert = { ...ADVERT, pushEvents: true }; + t.server.peer.notify('mcpl/manifestChanged', { revision: 'r2', domains: ['featureSets'] }); + await waitFor(() => count('mcpl/manifest') > manifests && count('tools/list') > lists, 'the manifest fetched again and applied'); + await roundTrip(t); + t.server.peer.notify('tools/hints', t.state.hint([{ name: 'peek', visible: true }])); + await waitFor(() => notices(t.fw).slice(before).some((x) => x.includes('peek()')), 'peek told'); + assert.match(notices(t.fw).slice(before).join('\n'), /peek\(\) — Peek around\./); + } finally { await t.done(); } }); test('a push\'s change that nothing takes is told as a notice (in process: a push refused before the push path reads it needs a stopping host)', async () => { @@ -1391,7 +1405,7 @@ test('a recovery whose own tools/list straddled a change starts no second recove } finally { await t.done(); } }); -test('look at a toolset whose options a server nests very deep still answers (in process: a manifest nested that deep isn\'t taken by the framework\'s manifest read, so no server reaches it)', async () => { +test('look at a toolset whose options a server nests very deep still answers (in process: the suite\'s scripted server writes its initialize answer with JSON.stringify, which can\'t write options that deep)', async () => { let deep: Json = { type: 'string' }; for (let i = 0; i < 20000; i++) deep = { type: 'object', properties: { a: deep } }; const { hub, c } = tappedController(); @@ -2067,7 +2081,7 @@ test('a look that restates the present counts as shown only what it showed: a to } finally { await t.done(); await far.close(); } }); -test('a tool whose schema holds a literal nested very deep, or whose description or class is an object, is announced and looked at', async () => { +test('a tool whose schema holds a literal nested very deep or a type that isn\'t text, or whose description or class entries are objects, is announced and looked at', async () => { const { t, holding, held } = await holdingServer(); try { holding.add('tools/list'); @@ -2079,13 +2093,33 @@ test('a tool whose schema holds a literal nested very deep, or whose description const dynamic = '"_meta":{"mcpl/presence":"dynamic","mcpl/visible":true}'; const deep = `${'{"a":'.repeat(20000)}1${'}'.repeat(20000)}`; rawLine(t, `{"jsonrpc":"2.0","id":${JSON.stringify(held[0]!.id)},"result":{"tools":[${listed},` - + `{"name":"peek","description":{"toString":1},"inputSchema":{"type":"object","properties":{}},"_meta":{"mcpl/presence":"dynamic","mcpl/visible":true,"mcpl/class":[{"toString":1}]}},` - + `{"name":"fixed","description":"Fixed.","inputSchema":{"type":"object","properties":{"a":{"const":${deep}},"b":{"enum":[${deep}]},"c":{"type":"string","default":${deep}}}},${dynamic}}],` + + `{"name":"peek","description":{"toString":1},"inputSchema":{"type":"object","properties":{}},"_meta":{"mcpl/presence":"dynamic","mcpl/visible":true,"mcpl/class":["game",{"toString":1}]}},` + + `{"name":"fixed","description":"Fixed.","inputSchema":{"type":"object","properties":{"a":{"const":${deep}},"b":{"enum":[${deep}]},"c":{"type":"string","default":${deep}},"d":{"type":{"toString":1}},"e":{"type":["string",{"toString":1}]}}},${dynamic}}],` + `"_meta":{"mcpl/rev":${t.state.rev}}}}`); await waitFor(() => notices(t.fw).slice(before).some((x) => x.includes('fixed(')), 'the tools announced'); - assert.match(notices(t.fw).slice(before).join('\n'), /fixed\(a\?: …, b\?: …, c\?: string =…\) — Fixed\./); + assert.match(notices(t.fw).slice(before).join('\n'), /fixed\(a\?: …, b\?: …, c\?: string =…, d\?: any, e\?: string\) — Fixed\./); const peek = await look(t.fw, { server: 'arc', tool: 'peek' }); - assert.match(peek, /^peek\(\)\ninputSchema:/, 'no description and no class line where they aren\'t text'); - assert.match(await look(t.fw, { server: 'arc', tool: 'fixed' }), /^fixed\(a\?: …, b\?: …, c\?: string =…\)\nFixed\./); + assert.match(peek, /^peek\(\)\nclass: game\ninputSchema:/, 'no description where it isn\'t text, and only the class entries that are'); + assert.match(await look(t.fw, { server: 'arc', tool: 'fixed' }), /^fixed\(a\?: …, b\?: …, c\?: string =…, d\?: any, e\?: string\)\nFixed\./); + } finally { await t.done(); } +}); + +test('a puppet look that restates the present, whose pair can\'t be stored, tells the change it found as a notice', async () => { + const t = await setup(); + try { + const cm = t.fw.framework.getAgent('scout')!.getContextManager(); + const add = cm.addMessage.bind(cm); + let refused = false; + cm.addMessage = ((...args: Parameters) => { + if (!refused) { + refused = true; + throw new Error('the store refused the write'); + } + return add(...args); + }) as typeof cm.addMessage; + const before = notices(t.fw).length; + t.state.statement = [{ name: 'peek', visible: true }]; + await assert.rejects(t.fw.framework.puppetToolCall('scout', 'toolsets--look', { server: 'arc' }), /the store refused the write/); + await waitFor(() => notices(t.fw).slice(before).some((x) => x.includes('peek()')), 'the change look found, told as a notice'); } finally { await t.done(); } }); From 40e7e17e7d41235675c74120925630a235dc3474 Mon Sep 17 00:00:00 2001 From: Octavia-2412 Date: Mon, 26 Oct 2026 03:12:02 -0400 Subject: [PATCH 31/46] fix(mcpl): a listing that breaks off tells the moves of the tools it reached The rebuild recorded a server's moves only once its listing was through, so a listing that broke off after a moved tool published that tool's new place and told nothing; the move stayed pending until a listing went through, which a server that keeps sending the same unreadable entry never gives. It now records each move as the listing reaches the moved tool: a listing that breaks off tells the moves of the tools it published, and the rest stay pending. Tests: - a listing with an unreadable entry (null) after the moved tool publishes the tool and tells its move once; this fails when the moves are recorded only once the listing is through, as in the commit before; - the test with the entry before the moved tool, which fails when every pending move is recorded before the loop, is renamed for its order. The two tests that wait for a rebuild to end wait for the framework's flag to read false, so a renamed flag times them out instead of letting them run on at once. Co-Authored-By: Claude Opus 5.5 --- src/framework.ts | 10 +++++++--- test/mcpl-tool-hints.test.ts | 22 +++++++++++++++++++--- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index e9a6db48d..35c2dc6ec 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -29795,9 +29795,12 @@ export class AgentFramework { // The static↔dynamic moves the lists made since the last rebuild // reach the tool list here (§4.2). The agent is told where each tool // went once this refresh publishes the list, and not by a refresh that - // doesn't, or whose listing of the server breaks off: the moves stay - // pending for one that does. + // doesn't; a listing that breaks off tells only the moves of the + // tools it reached. The rest stay pending for one that does. const presence = this.toolsetHub?.get(server.id); + const pending = presence?.presence.active ? new Map(presence.presence.moves().map((m) => [m.name, m])) : undefined; + const reached: Array<{ name: string; to: Presence }> = []; + if (presence && pending) movesListed.set(server.id, { controller: presence, moves: reached }); const dynamic = (name: string): boolean => !!presence?.presence.active && presence.presence.presenceOf(name) === 'dynamic'; for (const tool of result.tools) { if (!isToolAllowed(tool.name, config)) continue; @@ -29834,9 +29837,10 @@ export class AgentFramework { if (featureSet !== undefined) { toolFeatureSets.set(namespacedName, featureSet); } + const move = pending?.get(tool.name); + if (move) reached.push(move); } if (repeated.length > 0) repeats.push({ serverId: server.id, tools: repeated }); - if (presence?.presence.active) movesListed.set(server.id, { controller: presence, moves: presence.presence.moves() }); } catch { // Server may not support tools/list — skip silently. A live server // whose handshake advertised no MCP `tools` has, in effect, listed diff --git a/test/mcpl-tool-hints.test.ts b/test/mcpl-tool-hints.test.ts index dfb0eadbc..038b3165e 100644 --- a/test/mcpl-tool-hints.test.ts +++ b/test/mcpl-tool-hints.test.ts @@ -1525,14 +1525,14 @@ test('look naming a tool with no instance shows the definition act checks: the s } finally { await t.done(); } }); -test('RFC-014 §4.2: a rebuild whose listing of a server breaks off tells none of that server\'s moves, and the tool list doesn\'t show them', async () => { +test('RFC-014 §4.2: a rebuild whose listing of a server breaks off before a moved tool doesn\'t tell its move, and the tool list doesn\'t show it', async () => { const moved = withPresence('peek', 'static'); const { t, moveAndHold, release } = await movingServer([moved[0]!, null as unknown as Json, ...moved.slice(1)]); try { await moveAndHold(); release(); // A rebuild that changes nothing a caller sees has no public sign of its end: this waits on the framework's own flag. - await waitFor(() => !(t.fw.framework as unknown as { mcplToolRefreshInFlight: boolean }).mcplToolRefreshInFlight, 'the rebuild ended'); + await waitFor(() => (t.fw.framework as unknown as { mcplToolRefreshInFlight: boolean }).mcplToolRefreshInFlight === false, 'the rebuild ended'); await nextMacrotask(); assert.ok(!t.fw.framework.getAllTools().some((x) => x.name === 'arc--peek'), 'the tool list doesn\'t show peek'); assert.equal(notices(t.fw).filter((x) => x.includes('peek is in your tool list now')).length, 0, 'so the move isn\'t told'); @@ -1542,6 +1542,22 @@ test('RFC-014 §4.2: a rebuild whose listing of a server breaks off tells none o } }); +test('RFC-014 §4.2: a rebuild whose listing of a server breaks off after a moved tool tells its move once, and the tool list shows it', async () => { + const { t, moveAndHold, release } = await movingServer([...withPresence('peek', 'static'), null as unknown as Json]); + try { + await moveAndHold(); + release(); + await waitFor(() => t.fw.framework.getAllTools().some((x) => x.name === 'arc--peek'), 'the rebuild publishes peek'); + await waitFor(() => notices(t.fw).some((x) => x.includes('peek is in your tool list now')), 'the move told'); + await nextMacrotask(); + assert.equal(notices(t.fw).filter((x) => x.includes('peek is in your tool list now')).length, 1, 'once'); + } finally { + release(); + await t.done(); + } +}); + + test('RFC-014 §4.2: a tool a list moves to static is called both ways until the tool list shows it, then by its listed name', async () => { const { t, moveAndHold, release } = await movingServer(withPresence('peek', 'static')); try { @@ -1680,7 +1696,7 @@ test('RFC-014 §4.2: a rebuild that never publishes leaves its moves to the one await waitFor(() => s.toldMoved() === 1, 'told by the rebuild that published'); s.release(); // A rebuild that never publishes changes nothing a caller sees, so its end has no public sign: this waits on the framework's own flag. - await waitFor(() => !(s.t.fw.framework as unknown as { mcplToolRefreshInFlight: boolean }).mcplToolRefreshInFlight, 'the older rebuild ended'); + await waitFor(() => (s.t.fw.framework as unknown as { mcplToolRefreshInFlight: boolean }).mcplToolRefreshInFlight === false, 'the older rebuild ended'); await roundTrip(s.t); await nextMacrotask(); assert.equal(s.toldMoved(), 1, 'told once'); From 5e0cc9438d66d63b768110564686e1ded2a05306 Mon Sep 17 00:00:00 2001 From: Octavia-2412 Date: Mon, 26 Oct 2026 03:12:02 -0400 Subject: [PATCH 32/46] docs(mcpl): the README and fragment say a moved tool is called by name where either place shows it static MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README said a dynamic tool called by its prefixed name is taken while the tool list still offers it, and the fragment said a moved tool is called by name while the tool list offers it and through toolsets--act where the server's latest tools/list put it. Each states one direction of a move. The rule takes a call by name where the tool list or the server's latest list shows the tool static, and through toolsets--act where either shows it dynamic (RFC-014 §4.2), and both now say so. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 ++-- changelog.d/mcpl-toolsets-wiring.changed.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 2fc133933..084256475 100644 --- a/README.md +++ b/README.md @@ -501,8 +501,8 @@ stored `afterUser` injection (namespace `mcpl-toolsets`), at that agent's next start. A dynamic tool called by its prefixed name is refused, pointing at -`toolsets--act`, unless the tool list still offers it while a move waits for -its rebuild. Calls to an undeclared toolset's tools are refused while the +`toolsets--act`, unless the tool list or the server's latest list shows it as +static (a move waiting for its rebuild). Calls to an undeclared toolset's tools are refused while the host has that group closed (RFC-014 §5.4). Projection and surface mode are not implemented: a `surface` server's static tools are listed like any server's. diff --git a/changelog.d/mcpl-toolsets-wiring.changed.md b/changelog.d/mcpl-toolsets-wiring.changed.md index ac9f281d9..7fd20367e 100644 --- a/changelog.d/mcpl-toolsets-wiring.changed.md +++ b/changelog.d/mcpl-toolsets-wiring.changed.md @@ -1 +1 @@ -- MCPL RFC-014 tool presence (`toolHints: true`): until a move between static and dynamic reaches the model's tool list, the tool is called at either place, by its prefixed name while the tool list still offers it and through `toolsets--act` where the server's latest `tools/list` put it (RFC-014 §4.2). A dynamic tool called by its prefixed name is therefore no longer always refused. `toolsets--look` naming a tool shows the definition `toolsets--act` would check it against wherever `toolsets--act` would take it. +- MCPL RFC-014 tool presence (`toolHints: true`): until a move between static and dynamic reaches the model's tool list, the tool is called at either place: by its prefixed name where the tool list or the server's latest `tools/list` shows it static, and through `toolsets--act` where either shows it dynamic (RFC-014 §4.2). A dynamic tool called by its prefixed name is therefore no longer always refused. `toolsets--look` naming a tool shows the definition `toolsets--act` would check it against wherever `toolsets--act` would take it. From e9880709e17ea9059179e397beabf33d9b2b3149 Mon Sep 17 00:00:00 2001 From: Prudence-2217 Date: Mon, 26 Oct 2026 03:52:39 -0400 Subject: [PATCH 33/46] docs(mcpl): told's doc, typeSig's type comment, and the fixed fragment's order and first-sentence exceptions - told()'s doc says a taker that showed a change without its blocks (blocksShown: false) leaves its definitions uncounted. - typeSig's comment says entries of a type list that aren't text are left out, and a type with none reads as no type. - The fixed fragment adds the two exceptions to its order and first-sentence claims: a change a caller took and then couldn't show is told when the caller finds that out, after anything told meanwhile, and a puppet's open or look whose result the store refuses has already counted the definitions it rendered. Co-Authored-By: Claude Opus 5.5 --- changelog.d/mcpl-toolsets-hint-state.fixed.md | 2 +- src/mcpl/tool-signature.ts | 2 +- src/mcpl/toolset-host.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/changelog.d/mcpl-toolsets-hint-state.fixed.md b/changelog.d/mcpl-toolsets-hint-state.fixed.md index c90e8e398..29cb04141 100644 --- a/changelog.d/mcpl-toolsets-hint-state.fixed.md +++ b/changelog.d/mcpl-toolsets-hint-state.fixed.md @@ -1,6 +1,6 @@ - MCPL RFC-014 tool presence (`toolHints: true`), fixes to how hint changes are applied and told: - A `tools/list` is applied whole as its last page is read, so a hint read just after it isn't undone, and a malformed or timed-out list changes nothing. A paged list whose pages straddle a change, or a hint or `toolsets/list` entry naming a tool `tools/list` hasn't listed, is recovered from. - - Changes are told in the order they were read, each once: a result's change nobody shows with it is told as a notice before any later change, and a new tool's first sentence counts as shown only once text describing it reached the model. + - Changes are told in the order they were read, each once: a result's change nobody shows with it is told as a notice before any later change, and a new tool's first sentence counts as shown only once text describing it reached the model. A change a caller took and then couldn't show is told when the caller finds that out, after anything told meanwhile, and a puppet's open or look whose result the store refuses has already counted the definitions it rendered. - A change that would go as a notice to a server without the `pushEvents` grant is dropped, and `toolsets--look` shows the state. - A tool the operator's tool policy refuses never starts a recovery, and a tool listed twice is known by its first listing. - Rendering a server's definitions never throws: a description that isn't a string is left out, and a schema or a toolset's `options` nested very deep is cut short, rather than failing a notice or `toolsets--look`. diff --git a/src/mcpl/tool-signature.ts b/src/mcpl/tool-signature.ts index c1cdd2f27..9884e6517 100644 --- a/src/mcpl/tool-signature.ts +++ b/src/mcpl/tool-signature.ts @@ -51,7 +51,7 @@ function typeSig(schema: JsonSchema | undefined, depth = 0): string { if (schema.const !== undefined) return literal(schema.const); const alts = (schema.oneOf ?? schema.anyOf) as JsonSchema[] | undefined; if (Array.isArray(alts)) return alts.map((s) => typeSig(s, depth + 1)).join(' | '); - // Only text names a type; anything else a server sends there reads as no type. + // Only text names a type: entries that aren't text are left out, and a type with none reads as no type. const types = (Array.isArray(schema.type) ? schema.type : [schema.type]).filter((x): x is string => typeof x === 'string'); const t = types.length ? types.join('|') : undefined; switch (t) { diff --git a/src/mcpl/toolset-host.ts b/src/mcpl/toolset-host.ts index d0f55d3be..e629b4202 100644 --- a/src/mcpl/toolset-host.ts +++ b/src/mcpl/toolset-host.ts @@ -426,7 +426,7 @@ export class ToolsetController { return this.readChanges.get(carrier) ?? emptyChange(); } - /** A change its taker showed to an agent: its ended records count as told, and the definitions it describes as shown. */ + /** A change its taker showed to an agent: its ended records count as told, and the definitions it describes as shown, unless the taker showed it without its blocks (`blocksShown: false`). */ told(cs: ChangeSet): void { this.presence.markTold(cs.ended); if (cs.blocksShown !== false) this.presence.markShown(cs); From 4ad02a2e2dea5a446870c0a576e708b74cf2c1c1 Mon Sep 17 00:00:00 2001 From: Octavia-2412 Date: Mon, 26 Oct 2026 03:52:55 -0400 Subject: [PATCH 34/46] fix(mcpl): a move is told once the published list shows the tool where it went A listing that breaks off partway (an entry it can't read) publishes what it read, as 0.27.0 did. The commit before told only the moves of the tools the listing reached, so a tool it never reached that had moved to dynamic dropped out of the tool list with nothing told: by its listed name it was refused, and the model wasn't told to use toolsets--act. Now each pending move is checked against the list a rebuild publishes: a move to static is told once that list offers the tool, and a move to dynamic once it doesn't. A move the list doesn't show yet stays pending for a rebuild that does. Tests: - a listing broken off after a moved tool, and one broken off before it (as before); - a move a broken-off listing didn't publish is told, once, by the next rebuild, whose listing goes through; - a move to dynamic of a tool a broken-off listing never read is told, once, and act calls the tool. Each fails under a mutation: every pending move told at publication, moves to dynamic told only where the listing reached the tool, a move left pending dropped, and moves to static told whether offered or not. Co-Authored-By: Claude Opus 5.5 --- src/framework.ts | 20 ++++++++--------- test/mcpl-tool-hints.test.ts | 42 ++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 10 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index 35c2dc6ec..2eca7ade3 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -29774,7 +29774,7 @@ export class AgentFramework { // Each server's close count as its listing began. const closesAtListing = new Map(); const repeats: Array<{ serverId: string; tools: string[] }> = []; - const movesListed = new Map }>(); + const movesListed = new Map }>(); for (const server of [...this.mcplServerRegistry.getAllServers()]) { const config = this.mcplServerConfigs.get(server.id); @@ -29794,13 +29794,12 @@ export class AgentFramework { // server's tap, so its presence is current. // The static↔dynamic moves the lists made since the last rebuild // reach the tool list here (§4.2). The agent is told where each tool - // went once this refresh publishes the list, and not by a refresh that - // doesn't; a listing that breaks off tells only the moves of the - // tools it reached. The rest stay pending for one that does. + // went once this refresh publishes a list that shows it there: a move + // to static once the list offers the tool, a move to dynamic once it + // doesn't (a listing that breaks off publishes what it read). The + // rest stay pending for a refresh that does. const presence = this.toolsetHub?.get(server.id); - const pending = presence?.presence.active ? new Map(presence.presence.moves().map((m) => [m.name, m])) : undefined; - const reached: Array<{ name: string; to: Presence }> = []; - if (presence && pending) movesListed.set(server.id, { controller: presence, moves: reached }); + if (presence?.presence.active) movesListed.set(server.id, { controller: presence, prefix, moves: presence.presence.moves() }); const dynamic = (name: string): boolean => !!presence?.presence.active && presence.presence.presenceOf(name) === 'dynamic'; for (const tool of result.tools) { if (!isToolAllowed(tool.name, config)) continue; @@ -29837,8 +29836,6 @@ export class AgentFramework { if (featureSet !== undefined) { toolFeatureSets.set(namespacedName, featureSet); } - const move = pending?.get(tool.name); - if (move) reached.push(move); } if (repeated.length > 0) repeats.push({ serverId: server.id, tools: repeated }); } catch { @@ -29933,7 +29930,10 @@ export class AgentFramework { this.mcplToolClasses = toolClasses; this.mcplToolServers = toolServers; this.mcplListedServers = listedServers; - for (const [serverId, { controller, moves }] of movesListed) if (listedServers.has(serverId)) controller.applyMoves(moves); + const offered = new Set(this.mcplTools.map((tool) => tool.name)); + for (const [serverId, { controller, prefix, moves }] of movesListed) { + if (listedServers.has(serverId)) controller.applyMoves(moves.filter((m) => (m.to === 'static') === offered.has(`${prefix}--${m.name}`))); + } for (const { serverId, tools: repeatedTools } of repeats) { const one = repeatedTools.length === 1; const listed = repeatedTools.map((name) => logValue(name)).join(', '); diff --git a/test/mcpl-tool-hints.test.ts b/test/mcpl-tool-hints.test.ts index 038b3165e..7649ad889 100644 --- a/test/mcpl-tool-hints.test.ts +++ b/test/mcpl-tool-hints.test.ts @@ -1542,6 +1542,48 @@ test('RFC-014 §4.2: a rebuild whose listing of a server breaks off before a mov } }); +test('RFC-014 §4.2: a move a broken-off listing didn\'t publish is told by the next rebuild that does', async () => { + const moved = withPresence('peek', 'static'); + const list = [moved[0]!, null as unknown as Json, ...moved.slice(1)]; + const { t, moveAndHold, release } = await movingServer(list); + const toldMoved = () => notices(t.fw).filter((x) => x.includes('peek is in your tool list now')).length; + try { + await moveAndHold(); + release(); + await waitFor(() => (t.fw.framework as unknown as { mcplToolRefreshInFlight: boolean }).mcplToolRefreshInFlight === false, 'the broken rebuild ended'); + assert.equal(toldMoved(), 0, 'not told while the list doesn\'t show peek'); + list.splice(1, 1); + const lists = t.server.received.filter((m) => m.method === 'tools/list').length; + t.server.peer.notify('notifications/tools/list_changed', {}); + await waitFor(() => t.server.received.filter((m) => m.method === 'tools/list').length > lists, 'the next rebuild\'s tools/list asked for'); + release(); + await waitFor(() => t.fw.framework.getAllTools().some((x) => x.name === 'arc--peek'), 'the next rebuild publishes peek'); + await waitFor(() => toldMoved() > 0, 'the move told'); + await nextMacrotask(); + assert.equal(toldMoved(), 1, 'once'); + } finally { + release(); + await t.done(); + } +}); + +test('RFC-014 §4.2: a move to dynamic is told by a broken-off listing that no longer offers the tool, though it never read it', async () => { + const { t, moveAndHold, release } = await movingServer([null as unknown as Json, ...withPresence('status', 'dynamic')]); + const toldLeft = () => notices(t.fw).filter((x) => x.includes('status left your tool list')).length; + try { + await moveAndHold(); + release(); + await waitFor(() => !t.fw.framework.getAllTools().some((x) => x.name === 'arc--status'), 'the rebuild publishes a list without status'); + await waitFor(() => toldLeft() > 0, 'the move told'); + await nextMacrotask(); + assert.equal(toldLeft(), 1, 'once'); + assert.equal(resultText(await callTool(t.fw.framework, 'toolsets--act', { server: 'arc', tool: 'status', args: {} })), 'status done', 'and act calls it'); + } finally { + release(); + await t.done(); + } +}); + test('RFC-014 §4.2: a rebuild whose listing of a server breaks off after a moved tool tells its move once, and the tool list shows it', async () => { const { t, moveAndHold, release } = await movingServer([...withPresence('peek', 'static'), null as unknown as Json]); try { From 10462ef63b7c8652335f03793d01a0671264c355 Mon Sep 17 00:00:00 2001 From: Octavia-2412 Date: Mon, 26 Oct 2026 03:52:55 -0400 Subject: [PATCH 35/46] docs(mcpl): the either-place rule's docs name the server's latest tools/list, and the closed-group gate states its exception MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - shownAs, dynamicCallRefusal, resolveAct and resolveMcplTool said a call is taken where "the latest announcement" or what the model "was shown" puts the tool. What the rule reads is the tool list last published and the server's latest tools/list (presence follows each tools/list as it's read), and they now say so. - The README says calls to an undeclared toolset's tools are refused while the host has that group closed. A tool a list moves into a closed group is still taken by its listed name until the rebuild that publishes the move (RFC-014 §4.2), and the README now says so. Co-Authored-By: Claude Opus 5.5 --- README.md | 8 +++++--- src/framework.ts | 2 +- src/mcpl/tool-presence.ts | 6 +++--- src/mcpl/toolset-host.ts | 5 +++-- src/mcpl/toolsets-module.ts | 2 +- 5 files changed, 13 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 084256475..53b13a00f 100644 --- a/README.md +++ b/README.md @@ -502,9 +502,11 @@ start. A dynamic tool called by its prefixed name is refused, pointing at `toolsets--act`, unless the tool list or the server's latest list shows it as -static (a move waiting for its rebuild). Calls to an undeclared toolset's tools are refused while the -host has that group closed (RFC-014 §5.4). Projection and surface mode are not -implemented: a `surface` server's static tools are listed like any server's. +static (a move waiting for its rebuild). Calls to an undeclared toolset's +tools are refused while the host has that group closed (RFC-014 §5.4), except +by a tool's listed name until the rebuild that publishes its move into the +group. Projection and surface mode are not implemented: a `surface` server's +static tools are listed like any server's. #### Per-channel conversation routing (deprecated) diff --git a/src/framework.ts b/src/framework.ts index 2eca7ade3..08ad0b49e 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -23939,7 +23939,7 @@ export class AgentFramework { * Find the MCPL server for a tool: [serverId, prefix], or null. * The tool listing decides: the server whose tools/list listed the name * (offered to the model, or an RFC-014 dynamic tool, which dispatch then - * refuses by that name unless the published list or the latest announcement shows it as static), after its tool policy, as of the last tool refresh (a server that + * refuses by that name unless the published list or the server's latest tools/list puts it as static), after its tool policy, as of the last tool refresh (a server that * closed since keeps its names until the next one). A name the listing * doesn't hold (invented by the model, or no longer listed) goes to the * longest matching prefix. Prefixes that meet, such as `foo` and diff --git a/src/mcpl/tool-presence.ts b/src/mcpl/tool-presence.ts index bb39b0707..5f97799a6 100644 --- a/src/mcpl/tool-presence.ts +++ b/src/mcpl/tool-presence.ts @@ -263,9 +263,9 @@ export class ToolPresence { } /** RFC §4.2: "Until it applies the move, the tool keeps working where it - * is." Whether the model has been shown a tool at `place`: in the tool list - * last published, or in the latest announcement (its presence). Until a - * rebuild applies a move the two differ, and a call is taken at either. */ + * is." Whether a tool is at `place` in the tool list last published, or + * in the server's latest tools/list (its presence). Until a rebuild + * applies a move the two differ, and a call is taken at either. */ shownAs(name: string, place: Presence): boolean { if (!this.active) return place === 'static'; return this.published(name) === place || this.listedPresence(name) === place; diff --git a/src/mcpl/toolset-host.ts b/src/mcpl/toolset-host.ts index e629b4202..07962b785 100644 --- a/src/mcpl/toolset-host.ts +++ b/src/mcpl/toolset-host.ts @@ -375,8 +375,9 @@ export class ToolsetController { * A dynamic tool called by its namespaced name, as if it were listed (a * model imitating an earlier call, a module): refused, so every call into * a toolset goes through act, which knows its instance and gates closed - * groups (RFC §5.4, §5.5). Null for any other tool, including one the - * model was last shown as static (shownAs: RFC §4.2, until its move applies). + * groups (RFC §5.4, §5.5). Null for any other tool, including one the tool + * list last published or the server's latest tools/list puts as static + * (shownAs: RFC §4.2, until its move applies). */ dynamicCallRefusal(tool: string, calledAs: string): string | null { if (!this.presence.active || !this.presence.tool(tool) || this.presence.shownAs(tool, 'static')) return null; diff --git a/src/mcpl/toolsets-module.ts b/src/mcpl/toolsets-module.ts index 319164933..8a82ef940 100644 --- a/src/mcpl/toolsets-module.ts +++ b/src/mcpl/toolsets-module.ts @@ -205,7 +205,7 @@ export type ActTarget = { ok: true; serverId: string; tool: string; instance?: s * or the reason it can't be sent. The framework then dispatches it exactly as * a direct call of that tool, with `_meta["mcpl/instance"]` added (RFC §5.5). * Refused here, before anything is sent: a tool no presenting server has, a - * tool the model hasn't been shown as dynamic (RFC §4.2), an instance that isn't named where + * tool neither the published list nor the server's latest tools/list puts as dynamic (RFC §4.2), an instance that isn't named where * several are open, a group the host has closed (RFC §5.4), and arguments * that don't fit the definition in force for that instance (RFC §6.1). */ From 69873a1861839268350e44f9b91cd705bbc49ed2 Mon Sep 17 00:00:00 2001 From: Prudence-2217 Date: Mon, 26 Oct 2026 04:14:47 -0400 Subject: [PATCH 36/46] =?UTF-8?q?docs(mcpl):=20look's=20one-tool=20answer?= =?UTF-8?q?=20named=20whole,=20the=20first-sentence=20rule=20as=20it=20now?= =?UTF-8?q?=20holds,=20and=20RFC-014=20=C2=A74.2=20cited=20in=20its=20own?= =?UTF-8?q?=20words?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README and the changed fragment: toolsets--look naming a tool shows its signature, description, class and input schema, as the code renders it. - README: a new tool's first sentence is told with its definition until text describing it has reached the model, as the fixed fragment says; 0.27.0's sentence said only the first time its definition appears. - README: RFC-014 §4.2 keeps a tool working where it is until the move applies, no longer a paraphrase set out as a quote. Co-Authored-By: Claude Opus 5.5 --- README.md | 10 ++++++---- changelog.d/mcpl-toolsets-hint-state.changed.md | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 53b13a00f..6a4078cee 100644 --- a/README.md +++ b/README.md @@ -455,7 +455,8 @@ framework registers a `toolsets` module with four constant tools: anything nested or branching too far to the server, which checks every call itself; - `toolsets--look {server?, toolset?, instance?, tool?}` restates what is open - and what can be opened, or one tool's description and input schema. It + and what can be opened, or one tool's signature, description, class and + input schema. It re-reads the server first, and says so when it couldn't; what that changed is in the answer when look restates everything, and otherwise told as a notice; @@ -468,7 +469,8 @@ dynamic takes effect when the server's `tools/list` states it, and the tool list follows at its next rebuild that lists the server, whichever refresh brings it (a move is no reason to rebuild the tool list early). Until then the tool is called at either place, where the tool list shows it or where the -server's latest list put it (RFC-014 §4.2: it keeps working where it was), and +server's latest list put it (RFC-014 §4.2 keeps it working where it is until +the move applies), and once that rebuild has published the list, a notice says where it went. A list read while a rebuild is under way can undo a move that rebuild then publishes; neither is told, and the next rebuild puts the tool back. @@ -489,8 +491,8 @@ taken it, so changes are told in the order they were read (the agent's window can still show a call's result, a push's event, or a change its taker couldn't show after a change read later). Notices and reminders need the server's `pushEvents` grant, like any delivery; without it, a change -that would go as a notice is dropped, and `toolsets--look` shows the state. A new tool's first sentence is told the first -time its definition appears. Suggestions are shown, never acted on. Each +that would go as a notice is dropped, and `toolsets--look` shows the state. A new tool's first sentence is told with its +definition until text describing it has reached the model. Suggestions are shown, never acted on. Each `ended` id is told once while the host runs (told ids are kept in memory), short of rare interleavings, such as a `toolsets--look` overlapping other reads of the same server, that can tell one diff --git a/changelog.d/mcpl-toolsets-hint-state.changed.md b/changelog.d/mcpl-toolsets-hint-state.changed.md index 805328a9c..fc7568503 100644 --- a/changelog.d/mcpl-toolsets-hint-state.changed.md +++ b/changelog.d/mcpl-toolsets-hint-state.changed.md @@ -1,4 +1,4 @@ - MCPL RFC-014 tool presence (`toolHints: true`): - A move between static and dynamic takes effect when the server's `tools/list` states it, not when a hint announces it, and once the next rebuild of the model's tool list that lists the server has published it, a notice says where the tool went, unless a list read during that rebuild undid the move. A move is no reason to rebuild the tool list early, whether a hint announced it or the host's own re-read found it. - Each `ended` id is told once while the host runs: told ids are kept in memory, an id counts as told only once a notice or a result carrying it reached the agent, and it is forgotten once the server stops reporting it. Rare interleavings, such as a `toolsets--look` overlapping another read of the same server, can tell one twice. - - `toolsets--look` naming a tool shows its signature, description and input schema, as before; the README no longer calls that its whole definition. What look's own re-read of the server changed is in its answer when it restates everything, and otherwise told as a notice. + - `toolsets--look` naming a tool shows its signature, description, class and input schema, as before; the README no longer calls that its whole definition. What look's own re-read of the server changed is in its answer when it restates everything, and otherwise told as a notice. From 2432951e614933bf293fa05c29feb22349b6a6b3 Mon Sep 17 00:00:00 2001 From: Prudence-2217 Date: Mon, 26 Oct 2026 04:30:50 -0400 Subject: [PATCH 37/46] fix(mcpl): a recovery whose tools/list fails still reads toolsets/list, and a literal or look's JSON is cut by nesting depth on every runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A recovery whose tools/list fails goes on to toolsets/list, as 0.27.0's recovery and connect both do. Stopping there left the open instances staler than 0.27.0 did, and RFC-014 §6.1's order (toolsets/list after tools/list, so every tool it names is known) buys nothing once the list has failed. A recovery's own toolsets/list starts no other recovery, so this can't loop. - A schema's literal value (an enum's, a const or a default) and the JSON look shows are written out only when they nest at most 64 levels below the value, the bound definition identity already uses; past it a literal reads as … and look's JSON as "(nested too deeply to show)". The cut used to wait for JSON.stringify to overflow the stack, which happens at different depths on different Node versions: Node 24 throws on a literal 20,000 levels deep, and the newest Node in CI wrote it out in full into the model's tool text. Tests (requirements: a failed recovery list still leaves open instances read, tools/list first; a value is cut at one stated depth whatever the runtime): - "a recovery whose tools/list fails still reads toolsets/list, as connecting does", turned round from the test that pinned the stop; it fails at the round's base. - "a literal is written out up to 65 levels deep and cut to … past that, whatever the runtime's stack, and look shows a schema past the bound as too deep": fails at the round's base on Node 24, which writes the 66-level literal out. Co-Authored-By: Claude Opus 5.5 --- src/mcpl/tool-signature.ts | 22 +++++++++++----------- src/mcpl/toolset-host.ts | 10 ++++------ test/mcpl-tool-hints.test.ts | 30 +++++++++++++++++++++++++----- 3 files changed, 40 insertions(+), 22 deletions(-) diff --git a/src/mcpl/tool-signature.ts b/src/mcpl/tool-signature.ts index 9884e6517..6253a2888 100644 --- a/src/mcpl/tool-signature.ts +++ b/src/mcpl/tool-signature.ts @@ -24,13 +24,17 @@ export interface HintedTool { const isObj = (v: unknown): v is Record => typeof v === 'object' && v !== null && !Array.isArray(v); +/** How deep a server's value may nest and still be written out, the same on every runtime. */ +const DEEPEST = 64; + +function nestsWithin(value: unknown, depth = 0): boolean { + if (value === null || typeof value !== 'object') return true; + return depth <= DEEPEST && Object.values(value).every((v) => nestsWithin(v, depth + 1)); +} + /** A schema's literal value (an enum's, a const or a default) as JSON, or … where it's nested too deeply to write. */ function literal(value: unknown): string { - try { - return JSON.stringify(value) ?? 'null'; - } catch { - return '…'; - } + return nestsWithin(value) ? JSON.stringify(value) ?? 'null' : '…'; } function typeSig(schema: JsonSchema | undefined, depth = 0): string { @@ -116,7 +120,7 @@ export function firstSentence(text: unknown, max = 140): string { function canonicalJson(value: unknown, depth = 0): string { if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null'; - if (depth > 64) return '"…"'; + if (depth > DEEPEST) return '"…"'; if (Array.isArray(value)) return `[${value.map((v) => canonicalJson(v, depth + 1)).join(',')}]`; const obj = value as Record; return `{${Object.keys(obj) @@ -155,11 +159,7 @@ export function renderToolLines(tools: HintedTool[], shown: Shown, opts: { descr /** A server's value as JSON, or a note where it's nested too deeply to show. */ export function jsonText(value: unknown, space?: number): string { - try { - return JSON.stringify(value, null, space) ?? ''; - } catch { - return '(nested too deeply to show)'; - } + return nestsWithin(value) ? JSON.stringify(value, null, space) ?? '' : '(nested too deeply to show)'; } /** A tool's signature, description, class and input schema, for `look` naming a tool. */ diff --git a/src/mcpl/toolset-host.ts b/src/mcpl/toolset-host.ts index 07962b785..c4b474157 100644 --- a/src/mcpl/toolset-host.ts +++ b/src/mcpl/toolset-host.ts @@ -469,9 +469,9 @@ export class ToolsetController { if (!connection || !connection.isConnected) return; this.recovering = true; try { - const read = await this.ownToolList(connection); + await this.ownToolList(connection); // RFC §6.1: toolsets/list after tools/list, so every tool it names is known. - if (connection !== this.connection || !read) return; + if (connection !== this.connection) return; const lists = await this.ownToolsetList(connection, LIST_TIMEOUT_MS, 'recovery'); if (connection !== this.connection) return; this.deliver(lists); @@ -484,14 +484,12 @@ export class ToolsetController { } } - /** Whether the list was read; the tap applied it and told its change as it was read. */ - private async ownToolList(connection: McplServerConnection): Promise { + /** The tap applies the list and tells its change as it's read. */ + private async ownToolList(connection: McplServerConnection): Promise { try { await connection.sendToolsList(); - return true; } catch (error) { this.log(`tools/list failed: ${this.failure(error)}`); - return false; } } diff --git a/test/mcpl-tool-hints.test.ts b/test/mcpl-tool-hints.test.ts index 7649ad889..bbf6c68a6 100644 --- a/test/mcpl-tool-hints.test.ts +++ b/test/mcpl-tool-hints.test.ts @@ -1124,7 +1124,7 @@ test('a toolsets/list naming a tool tools/list hasn\'t listed is recovered from, } finally { await t.done(); } }); -test('a recovery whose tools/list fails reads no toolsets/list', async () => { +test('a recovery whose tools/list fails still reads toolsets/list, as connecting does', async () => { let failing = false; const t = await setup((msg, peer) => { if (msg.method !== 'tools/list' || !failing) return false; @@ -1137,10 +1137,9 @@ test('a recovery whose tools/list fails reads no toolsets/list', async () => { t.state.next(); t.server.peer.notify('tools/hints', t.state.hint([])); await waitFor(() => t.server.received.slice(from).some((m) => m.method === 'tools/list'), 'the recovery read tools/list'); - await roundTrip(t); - await settle(400); // past the 200 ms recovery debounce - await roundTrip(t); // and the server has read whatever the host sent before it - assert.ok(!t.server.received.slice(from).some((m) => m.method === 'toolsets/list')); + await waitFor(() => t.server.received.slice(from).some((m) => m.method === 'toolsets/list'), 'and then toolsets/list'); + const order = t.server.received.slice(from).map((m) => m.method).filter((m) => m === 'tools/list' || m === 'toolsets/list'); + assert.deepEqual(order.slice(0, 2), ['tools/list', 'toolsets/list'], 'tools/list first (RFC-014 §6.1)'); } finally { await t.done(); } }); @@ -2162,6 +2161,27 @@ test('a tool whose schema holds a literal nested very deep or a type that isn\'t } finally { await t.done(); } }); +test('a literal is written out up to 65 levels deep and cut to … past that, whatever the runtime\'s stack, and look shows a schema past the bound as too deep', async () => { + const { t, holding, held } = await holdingServer(); + try { + holding.add('tools/list'); + const before = notices(t.fw).length; + t.server.peer.notify('notifications/tools/list_changed', {}); + await waitFor(() => held.length === 1, 'the tool list asked for'); + holding.clear(); + const listed = TOOLS.filter((x) => x.name !== 'peek').map((x) => JSON.stringify(x)).join(','); + const dynamic = '"_meta":{"mcpl/presence":"dynamic","mcpl/visible":true}'; + const nested = (levels: number) => `${'{"a":'.repeat(levels)}1${'}'.repeat(levels)}`; + rawLine(t, `{"jsonrpc":"2.0","id":${JSON.stringify(held[0]!.id)},"result":{"tools":[${listed},` + + `{"name":"bounded","description":"Bounded.","inputSchema":{"type":"object","properties":{"a":{"const":${nested(65)}},"b":{"const":${nested(66)}}}},${dynamic}}],` + + `"_meta":{"mcpl/rev":${t.state.rev}}}}`); + await waitFor(() => notices(t.fw).slice(before).some((x) => x.includes('bounded(')), 'the tool announced'); + const line = notices(t.fw).slice(before).join('\n').split('\n').find((l) => l.includes('bounded('))!; + assert.ok(line.includes(`a?: ${nested(65)}, b?: …)`), line.slice(0, 200)); + assert.match(await look(t.fw, { server: 'arc', tool: 'bounded' }), /inputSchema:\n\(nested too deeply to show\)/); + } finally { await t.done(); } +}); + test('a puppet look that restates the present, whose pair can\'t be stored, tells the change it found as a notice', async () => { const t = await setup(); try { From 6a6549e100bd1946d1a3dfe43c2dc31eac16e0d9 Mon Sep 17 00:00:00 2001 From: Octavia-2412 Date: Mon, 26 Oct 2026 04:37:56 -0400 Subject: [PATCH 38/46] docs(mcpl): a model's call counts as reaching its agent once its result is handed back; the unreadable-result test runs where the runtime can't write its nesting - The fixed fragment and the README said a result's tool change goes with it only where the result reaches an agent, naming the model's own call. A turn stopped while the call is out drops the result when it comes back (the tool-result handler drops a result for an agent that isn't waiting for tools, or is gone), and the change that went with it is dropped untold, as in 0.27.0. Both texts now say the model's own call counts as reaching the agent once its result is handed back, and name that case. changeTaker's and executeMcplToolCall's docs say where they take a change: a result stored in an agent's window (the puppet's) or handed to an agent awaiting it. Run at this round's base and at 0.27.0 (0a4a2c69): a model's call the server holds, abortInference while it's out, then an answer whose hints show a new tool. At both, the agent goes from waiting_for_tools to idle, the result is dropped (tool:result_dropped), no notice is written, and toolsets--look shows the tool. - "a model's call whose result the host can't read still tells the tool change it carried, as a notice" made its result unreadable with structured content 100,000 levels deep. JSON.stringify throws on that nesting on Node 24.22.1 and writes it out on Node 26.12.1, where the test passed through the inline cap instead. It now runs only where the runtime throws on the nesting, and says why it's skipped elsewhere. Co-Authored-By: Claude Opus 5.5 --- README.md | 5 ++++- changelog.d/mcpl-toolsets-wiring.fixed.md | 2 +- src/framework.ts | 6 ++++-- test/mcpl-toolsets-wiring.test.ts | 11 +++++++++-- 4 files changed, 18 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 6a4078cee..0202ba9bb 100644 --- a/README.md +++ b/README.md @@ -489,7 +489,10 @@ fails after the result was read. A call's change also goes as a notice, ahead of result, when a later change goes to the agent before the call's caller has taken it, so changes are told in the order they were read (the agent's window can still show a call's result, a push's event, or a change its taker couldn't -show after a change read later). Notices and reminders +show after a change read later). The model's own call's change counts as told +once its result is handed back: if the turn is stopped, or the agent is gone, +while the call is out, the result is dropped and the change with it, and +`toolsets--look` shows the state it left. Notices and reminders need the server's `pushEvents` grant, like any delivery; without it, a change that would go as a notice is dropped, and `toolsets--look` shows the state. A new tool's first sentence is told with its definition until text describing it has reached the model. Suggestions are shown, never acted on. Each diff --git a/changelog.d/mcpl-toolsets-wiring.fixed.md b/changelog.d/mcpl-toolsets-wiring.fixed.md index b595101b1..b7607e44b 100644 --- a/changelog.d/mcpl-toolsets-wiring.fixed.md +++ b/changelog.d/mcpl-toolsets-wiring.fixed.md @@ -1,5 +1,5 @@ - MCPL RFC-014 tool presence (`toolHints: true`), fixes to how the toolsets host is wired into the framework: - - A result's tool change goes with it only where the result reaches an agent: the model's own call, and the operator's puppet, whose call and result are stored in the agent's window. A script's, a module's or a host's own call leaves it to a notice. A dynamic tool's change that wouldn't fit whole within its result's inline cap, or whose call fails after its result was read, goes as a notice too. A result that is structured content alone shows that content, as its JSON, before the change that goes with it. + - A result's tool change goes with it only where the result reaches an agent: the model's own call, and the operator's puppet, whose call and result are stored in the agent's window. The model's own call counts as reaching it once its result is handed back: if the turn is stopped, or the agent is gone, while the call is out, the result is dropped and its change with it, untold, as in 0.27.0, and `toolsets--look` shows the state it left. A script's, a module's or a host's own call leaves it to a notice. A dynamic tool's change that wouldn't fit whole within its result's inline cap, or whose call fails after its result was read, goes as a notice too. A result that is structured content alone shows that content, as its JSON, before the change that goes with it. - An open or close whose outcome is unknown is reported as unconfirmed rather than as not done, and names `toolsets--look`, which says when it couldn't ask the server. A selector that is present but isn't a name is refused (null reads as left out), and a refusal for a name the server doesn't have says what it has. - Removing a server, or restarting it under another id or with `toolHints` off, leaves no toolset state behind. A stop or a disconnect while a `toolHints` server is still connecting ends the connect. - The capability mask no longer reports `toolHints`, `toolsets` and `surface` as masked: `toolHints: false` turns hints off. Operator tool-name patterns naming a dynamic tool count as matched. diff --git a/src/framework.ts b/src/framework.ts index 94b59f664..5da0814fd 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -22526,7 +22526,8 @@ export class AgentFramework { private puppetChanges?: Map; /** RFC-014 §6.1: a change reaches the agent once, so it goes with a result - * only where the result reaches an agent; null where it may not. */ + * only where the result is stored in an agent's window (the puppet's) or + * handed to an agent awaiting it; null where it may not reach one. */ private changeTaker(call: ToolCall): ((taken: TakenChange) => void) | null { if (call.callerAgentName === undefined) return null; const puppet = this.puppetChanges?.get(call.id); @@ -22581,7 +22582,8 @@ export class AgentFramework { /** A programmatic call of an MCPL tool: `serverId` and `prefix` as * resolveMcplTool matched them, or as toolsets--act resolved them * (toolsetActCalls). Its RFC-014 tool change goes with its result only - * where the result reaches an agent (changeTaker); any other caller's + * where the result is stored in an agent's window or handed to an agent + * awaiting it (changeTaker); any other caller's * result may never reach one, so its change is told as a notice * (ToolsetController.tellUnlessClaimed). */ private async executeMcplToolCall(call: ToolCall, serverId: string, prefix: string): Promise { diff --git a/test/mcpl-toolsets-wiring.test.ts b/test/mcpl-toolsets-wiring.test.ts index 3958de0f3..369a3bacd 100644 --- a/test/mcpl-toolsets-wiring.test.ts +++ b/test/mcpl-toolsets-wiring.test.ts @@ -313,9 +313,16 @@ test('a model\'s call whose tool change fits goes with its result even where a d } }); -test('a model\'s call whose result the host can\'t read still tells the tool change it carried, as a notice', async () => { +const nestedPastStringify = `${'['.repeat(100_000)}${']'.repeat(100_000)}`; +// The host reads a structured-only result with JSON.stringify, which throws on +// this nesting where the runtime's stack runs out (Node 24) and writes it out +// where it doesn't (Node 26). +const stringifyOverflows = (() => { + try { JSON.stringify(JSON.parse(nestedPastStringify)); return false; } catch { return true; } +})(); + +test('a model\'s call whose result the host can\'t read still tells the tool change it carried, as a notice', { skip: !stringifyOverflows && 'this runtime writes the nesting out, so the host reads the result' }, async () => { let rev = 0; - const nestedPastStringify = `${'['.repeat(100_000)}${']'.repeat(100_000)}`; const server = await startScriptedServer({ advert: { ...ADVERT, pushEvents: true, featureSets: { ...(ADVERT.featureSets as Json), ...EVENTS } }, tools: [STATUS, PEEK], From 59be5cb5d65ded34fc597cdfdc20d44c5615b38c Mon Sep 17 00:00:00 2001 From: Octavia-2412 Date: Mon, 26 Oct 2026 04:40:06 -0400 Subject: [PATCH 39/46] test(mcpl): the unreadable-result test's runtime probe parses the nesting outside its try MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The probe deciding whether "a model's call whose result the host can't read…" runs caught a throw from JSON.parse as well as from JSON.stringify. A runtime whose parse threw on that nesting would have run the test under another failure, the host unable to parse the message rather than to read the result. Parsing outside the try makes such a runtime fail when the file loads. Co-Authored-By: Claude Opus 5.5 --- test/mcpl-toolsets-wiring.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/mcpl-toolsets-wiring.test.ts b/test/mcpl-toolsets-wiring.test.ts index 369a3bacd..5dc1e0574 100644 --- a/test/mcpl-toolsets-wiring.test.ts +++ b/test/mcpl-toolsets-wiring.test.ts @@ -318,7 +318,8 @@ const nestedPastStringify = `${'['.repeat(100_000)}${']'.repeat(100_000)}`; // this nesting where the runtime's stack runs out (Node 24) and writes it out // where it doesn't (Node 26). const stringifyOverflows = (() => { - try { JSON.stringify(JSON.parse(nestedPastStringify)); return false; } catch { return true; } + const nested: unknown = JSON.parse(nestedPastStringify); + try { JSON.stringify(nested); return false; } catch { return true; } })(); test('a model\'s call whose result the host can\'t read still tells the tool change it carried, as a notice', { skip: !stringifyOverflows && 'this runtime writes the nesting out, so the host reads the result' }, async () => { From 30a86f132e1977ee4216f5964514d5770bd80451 Mon Sep 17 00:00:00 2001 From: Prudence-2217 Date: Mon, 26 Oct 2026 04:41:35 -0400 Subject: [PATCH 40/46] docs(mcpl): recover() says what a failed tools/list leaves; its test pins that nothing more is read after it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - recover()'s doc and its RFC-014 §6.1 comment say every tool the statement names is known unless tools/list failed: a tool the last list lacks then waits for a later recovery. - The failed-list recovery test, after toolsets/list, waits past the 200 ms recovery debounce and a round trip, then asserts the server read exactly tools/list and toolsets/list, so it also pins that a failed list starts no further recovery. With a recovery scheduled again on the failed list, it fails. Co-Authored-By: Claude Opus 5.5 --- src/mcpl/toolset-host.ts | 8 +++++--- test/mcpl-tool-hints.test.ts | 5 ++++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/mcpl/toolset-host.ts b/src/mcpl/toolset-host.ts index c4b474157..ec68fcf2c 100644 --- a/src/mcpl/toolset-host.ts +++ b/src/mcpl/toolset-host.ts @@ -461,8 +461,10 @@ export class ToolsetController { /** * Recovering: re-read tools/list (presence, declared definitions), then * toolsets/list (dynamic-tool state in full), so every tool the statement - * names is already known; each applies where it's read. What changed is - * told. (stdio and WebSocket deliver in order, so no early rev is held.) + * names is already known, unless tools/list failed: a tool the last list + * lacks then waits for a later recovery. Each applies where it's read. What + * changed is told. (stdio and WebSocket deliver in order, so no early rev is + * held.) */ private async recover(): Promise { const connection = this.connection; @@ -470,7 +472,7 @@ export class ToolsetController { this.recovering = true; try { await this.ownToolList(connection); - // RFC §6.1: toolsets/list after tools/list, so every tool it names is known. + // RFC §6.1: toolsets/list after tools/list, so every tool it names is known, unless tools/list failed. if (connection !== this.connection) return; const lists = await this.ownToolsetList(connection, LIST_TIMEOUT_MS, 'recovery'); if (connection !== this.connection) return; diff --git a/test/mcpl-tool-hints.test.ts b/test/mcpl-tool-hints.test.ts index bbf6c68a6..298e43e5e 100644 --- a/test/mcpl-tool-hints.test.ts +++ b/test/mcpl-tool-hints.test.ts @@ -1138,8 +1138,11 @@ test('a recovery whose tools/list fails still reads toolsets/list, as connecting t.server.peer.notify('tools/hints', t.state.hint([])); await waitFor(() => t.server.received.slice(from).some((m) => m.method === 'tools/list'), 'the recovery read tools/list'); await waitFor(() => t.server.received.slice(from).some((m) => m.method === 'toolsets/list'), 'and then toolsets/list'); + await roundTrip(t); + await settle(400); // past the 200 ms recovery debounce + await roundTrip(t); // and the server has read whatever the host sent before it const order = t.server.received.slice(from).map((m) => m.method).filter((m) => m === 'tools/list' || m === 'toolsets/list'); - assert.deepEqual(order.slice(0, 2), ['tools/list', 'toolsets/list'], 'tools/list first (RFC-014 §6.1)'); + assert.deepEqual(order, ['tools/list', 'toolsets/list'], 'tools/list first (RFC-014 §6.1), and no further recovery'); } finally { await t.done(); } }); From 6e7fe4264c9e70e7e18e6537ddfec746b6dfd7b8 Mon Sep 17 00:00:00 2001 From: Prudence-2217 Date: Mon, 26 Oct 2026 05:42:26 -0400 Subject: [PATCH 41/46] fix(mcpl): look's JSON says when it's too long to write, the README names the first sentence's one exception, and two tests pin what they promised - jsonText cuts by nesting depth as before, and JSON.stringify can still throw where the pretty-printed value passes the runtime's string length, which nothing bounds a message's size against; look then says "(too long to show)" rather than failing. - The README's first-sentence rule names its exception, as the fixed fragment does: a puppet's open or look whose result the store refuses has already counted the definition shown. - The failed-list recovery test also asserts that what the recovery's toolsets/list says reaches the agent as a notice; with the recovery's delivery taken out, it times out waiting for it. - The literal-bound test pins look's JSON bound at its edge: a schema whose deepest object sits 64 levels below it is shown, one at 65 is too deep to show. Co-Authored-By: Claude Opus 5.5 --- README.md | 3 ++- src/mcpl/tool-signature.ts | 9 +++++++-- test/mcpl-tool-hints.test.ts | 11 +++++++++-- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 0202ba9bb..64d9d139a 100644 --- a/README.md +++ b/README.md @@ -495,7 +495,8 @@ while the call is out, the result is dropped and the change with it, and `toolsets--look` shows the state it left. Notices and reminders need the server's `pushEvents` grant, like any delivery; without it, a change that would go as a notice is dropped, and `toolsets--look` shows the state. A new tool's first sentence is told with its -definition until text describing it has reached the model. Suggestions are shown, never acted on. Each +definition until text describing it has reached the model, except that a puppet's open or look whose result +the store refuses has already counted it. Suggestions are shown, never acted on. Each `ended` id is told once while the host runs (told ids are kept in memory), short of rare interleavings, such as a `toolsets--look` overlapping other reads of the same server, that can tell one diff --git a/src/mcpl/tool-signature.ts b/src/mcpl/tool-signature.ts index 6253a2888..aec354f7c 100644 --- a/src/mcpl/tool-signature.ts +++ b/src/mcpl/tool-signature.ts @@ -157,9 +157,14 @@ export function renderToolLines(tools: HintedTool[], shown: Shown, opts: { descr }); } -/** A server's value as JSON, or a note where it's nested too deeply to show. */ +/** A server's value as JSON, or a note where it's nested too deeply, or too long, to show. */ export function jsonText(value: unknown, space?: number): string { - return nestsWithin(value) ? JSON.stringify(value, null, space) ?? '' : '(nested too deeply to show)'; + if (!nestsWithin(value)) return '(nested too deeply to show)'; + try { + return JSON.stringify(value, null, space) ?? ''; + } catch { + return '(too long to show)'; + } } /** A tool's signature, description, class and input schema, for `look` naming a tool. */ diff --git a/test/mcpl-tool-hints.test.ts b/test/mcpl-tool-hints.test.ts index 298e43e5e..b9ae80361 100644 --- a/test/mcpl-tool-hints.test.ts +++ b/test/mcpl-tool-hints.test.ts @@ -1124,7 +1124,7 @@ test('a toolsets/list naming a tool tools/list hasn\'t listed is recovered from, } finally { await t.done(); } }); -test('a recovery whose tools/list fails still reads toolsets/list, as connecting does', async () => { +test('a recovery whose tools/list fails still reads toolsets/list, as connecting does, and tells what it says', async () => { let failing = false; const t = await setup((msg, peer) => { if (msg.method !== 'tools/list' || !failing) return false; @@ -1134,10 +1134,13 @@ test('a recovery whose tools/list fails still reads toolsets/list, as connecting try { failing = true; const from = t.server.received.length; + const before = notices(t.fw).length; + t.state.open = [{ toolset: 'build', title: 'Workshop' }]; t.state.next(); t.server.peer.notify('tools/hints', t.state.hint([])); await waitFor(() => t.server.received.slice(from).some((m) => m.method === 'tools/list'), 'the recovery read tools/list'); await waitFor(() => t.server.received.slice(from).some((m) => m.method === 'toolsets/list'), 'and then toolsets/list'); + await waitFor(() => notices(t.fw).slice(before).some((x) => x.includes('Workshop')), 'what toolsets/list said, told'); await roundTrip(t); await settle(400); // past the 200 ms recovery debounce await roundTrip(t); // and the server has read whatever the host sent before it @@ -2176,12 +2179,16 @@ test('a literal is written out up to 65 levels deep and cut to … past that, wh const dynamic = '"_meta":{"mcpl/presence":"dynamic","mcpl/visible":true}'; const nested = (levels: number) => `${'{"a":'.repeat(levels)}1${'}'.repeat(levels)}`; rawLine(t, `{"jsonrpc":"2.0","id":${JSON.stringify(held[0]!.id)},"result":{"tools":[${listed},` - + `{"name":"bounded","description":"Bounded.","inputSchema":{"type":"object","properties":{"a":{"const":${nested(65)}},"b":{"const":${nested(66)}}}},${dynamic}}],` + + `{"name":"bounded","description":"Bounded.","inputSchema":{"type":"object","properties":{"a":{"const":${nested(65)}},"b":{"const":${nested(66)}}}},${dynamic}},` + + `{"name":"edge","description":"Edge.","inputSchema":{"type":"object","properties":{"a":{"const":${nested(62)}}}},${dynamic}},` + + `{"name":"past","description":"Past.","inputSchema":{"type":"object","properties":{"a":{"const":${nested(63)}}}},${dynamic}}],` + `"_meta":{"mcpl/rev":${t.state.rev}}}}`); await waitFor(() => notices(t.fw).slice(before).some((x) => x.includes('bounded(')), 'the tool announced'); const line = notices(t.fw).slice(before).join('\n').split('\n').find((l) => l.includes('bounded('))!; assert.ok(line.includes(`a?: ${nested(65)}, b?: …)`), line.slice(0, 200)); assert.match(await look(t.fw, { server: 'arc', tool: 'bounded' }), /inputSchema:\n\(nested too deeply to show\)/); + assert.match(await look(t.fw, { server: 'arc', tool: 'edge' }), /inputSchema:\n\{\n "type": "object"/); + assert.match(await look(t.fw, { server: 'arc', tool: 'past' }), /inputSchema:\n\(nested too deeply to show\)/); } finally { await t.done(); } }); From 03d1fe137e1bbeae77a3ab75721c1c3a2e58a0b6 Mon Sep 17 00:00:00 2001 From: Octavia-2412 Date: Mon, 26 Oct 2026 05:47:07 -0400 Subject: [PATCH 42/46] fix(mcpl): the puppet shows a structured-only error's content as its JSON where a change joins it; texts name a server's last dynamic tool and say a dropped call's change counts as told - Through the puppet, a server's error returned before the JSON view of a structured-only result was built, so an error whose only words were its structured content was stored as the generic "without saying what went wrong" text, with the tool change after it. Where a change joins such an error, its text is now the content's JSON, as for a success and as the model's own dispatch already reads an error. A server without toolHints keeps 0.27.0's reading. - The README: a server with no toolsets stops presenting them once its latest tools/list has no dynamic tool, so until the rebuild, act takes its last one only with `server` named. - The wiring fragment said a dropped call's change went "untold"; it counts as told when the result is handed back, and isn't told again. Tests: - "the puppet stores a server's error that is structured content alone as that content's JSON, then the tool change" fails at 6e7fe426 on its own assertion, and at 0.27.0 (0a4a2c69). - "a server without toolHints keeps 0.27.0's reading of a structured-only error" passes at 0.27.0, and fails when the error view is built whether or not a change joins. Co-Authored-By: Claude Opus 5.5 --- README.md | 5 ++- changelog.d/mcpl-toolsets-wiring.fixed.md | 2 +- src/framework.ts | 13 +++++--- test/mcpl-toolsets-wiring.test.ts | 37 +++++++++++++++++++++++ 4 files changed, 51 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 64d9d139a..3f20e0ad7 100644 --- a/README.md +++ b/README.md @@ -462,7 +462,10 @@ framework registers a `toolsets` module with four constant tools: notice; - `toolsets--close {server?, toolset?, instance?}` closes it. -`server` can be left out when only one server has the name. Hint changes apply +`server` can be left out when only one server has the name. A server with no +toolsets stops presenting them once its latest `tools/list` has no dynamic +tool, so until the rebuild, act takes its last one only with `server` named. +Hint changes apply as their message is read, in `rev` order, whatever becomes of the message (a refused push, a result that came after its timeout). A move between static and dynamic takes effect when the server's `tools/list` states it, and the tool diff --git a/changelog.d/mcpl-toolsets-wiring.fixed.md b/changelog.d/mcpl-toolsets-wiring.fixed.md index b7607e44b..6767e381a 100644 --- a/changelog.d/mcpl-toolsets-wiring.fixed.md +++ b/changelog.d/mcpl-toolsets-wiring.fixed.md @@ -1,5 +1,5 @@ - MCPL RFC-014 tool presence (`toolHints: true`), fixes to how the toolsets host is wired into the framework: - - A result's tool change goes with it only where the result reaches an agent: the model's own call, and the operator's puppet, whose call and result are stored in the agent's window. The model's own call counts as reaching it once its result is handed back: if the turn is stopped, or the agent is gone, while the call is out, the result is dropped and its change with it, untold, as in 0.27.0, and `toolsets--look` shows the state it left. A script's, a module's or a host's own call leaves it to a notice. A dynamic tool's change that wouldn't fit whole within its result's inline cap, or whose call fails after its result was read, goes as a notice too. A result that is structured content alone shows that content, as its JSON, before the change that goes with it. + - A result's tool change goes with it only where the result reaches an agent: the model's own call, and the operator's puppet, whose call and result are stored in the agent's window. The model's own call counts as reaching it once its result is handed back. If the turn is stopped, or the agent is gone, while the call is out, the result is dropped, and the change with it, counted as told and not told again, as in 0.27.0; `toolsets--look` shows the state it left. A script's, a module's or a host's own call leaves it to a notice. A dynamic tool's change that wouldn't fit whole within its result's inline cap, or whose call fails after its result was read, goes as a notice too. A result that is structured content alone shows that content, as its JSON, before the change that goes with it. - An open or close whose outcome is unknown is reported as unconfirmed rather than as not done, and names `toolsets--look`, which says when it couldn't ask the server. A selector that is present but isn't a name is refused (null reads as left out), and a refusal for a name the server doesn't have says what it has. - Removing a server, or restarting it under another id or with `toolHints` off, leaves no toolset state behind. A stop or a disconnect while a `toolHints` server is still connecting ends the connect. - The capability mask no longer reports `toolHints`, `toolsets` and `surface` as masked: `toolHints: false` turns hints off. Operator tool-name patterns naming a dynamic tool count as matched. diff --git a/src/framework.ts b/src/framework.ts index 5da0814fd..e7435dfe9 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -22627,12 +22627,17 @@ export class AgentFramework { }; // A server's error is a failure here too, as the model's dispatch // reads it: its text (mcplToolFailure), without its other blocks, and - // its structured content, kept by presence. A success is the server's - // own blocks and structured content, for the caller to use in process. + // its structured content, kept by presence; where a change joins an + // error that is structured content alone, that content's JSON, as below. + // A success is the server's own blocks and structured content, for the + // caller to use in process. const direct = AgentFramework.directToolResult(result, serverId, call.name); if (direct.isError) { - const { data: _blocks, ...failure } = direct; - return withChange(failure); + const { data: blocks, ...failure } = direct; + const shown: ToolResult = taken && !(Array.isArray(blocks) && blocks.length) && Object.prototype.hasOwnProperty.call(direct, 'structured') + ? { ...failure, error: JSON.stringify(direct.structured) ?? 'null' } + : failure; + return withChange(shown, failure); } // RFC-005 §6.2: register an MCPL peer's references with this server, so // a stub later made of them is bound and fetch_reference can fetch diff --git a/test/mcpl-toolsets-wiring.test.ts b/test/mcpl-toolsets-wiring.test.ts index 5dc1e0574..807a5ce17 100644 --- a/test/mcpl-toolsets-wiring.test.ts +++ b/test/mcpl-toolsets-wiring.test.ts @@ -190,6 +190,28 @@ test('a server without toolHints keeps 0.27.0\'s reading of a structured-only re } }); +test('a server without toolHints keeps 0.27.0\'s reading of a structured-only error: the puppet stores the error\'s text, not the content', async () => { + const server = await startScriptedServer({ + advert: { featureSets: ADVERT.featureSets }, + tools: [STATUS], + on: (msg, peer) => { + if (msg.method === 'tools/call') { peer.reply(msg.id, { content: [], structuredContent: { reason: 'jammed' }, isError: true }); return; } + if (msg.id !== undefined) peer.error(msg.id, -32601, 'Method not found'); + }, + }); + const fw = await makeFramework([{ id: 'arc', toolPrefix: 'arc', url: server.url } as McplServerConfig]); + try { + await waitFor(() => fw.framework.getAllTools().some((t) => t.name === 'arc--status'), 'static tools listed'); + const { toolUseId } = await fw.framework.puppetToolCall('scout', 'arc--status', {}); + const text = storedResult(fw, toolUseId); + assert.match(text, /without saying what went wrong/); + assert.doesNotMatch(text, /jammed/); + } finally { + await fw.stop(); + await server.close(); + } +}); + test('the puppet stores a result that is structured content alone as that content\'s JSON, then the tool change', async () => { const { server } = await hintingServer({ content: [], structuredContent: { score: 42 } }); const fw = await connectedTo(server); @@ -205,6 +227,21 @@ test('the puppet stores a result that is structured content alone as that conten } }); +test('the puppet stores a server\'s error that is structured content alone as that content\'s JSON, then the tool change', async () => { + const { server } = await hintingServer({ content: [], structuredContent: { reason: 'jammed' }, isError: true }); + const fw = await connectedTo(server); + try { + const { toolUseId } = await fw.framework.puppetToolCall('scout', 'arc--status', {}); + const text = storedResult(fw, toolUseId); + assert.match(text, /\\"reason\\":\\"jammed\\"/); + assert.match(text, /peek\(\)/); + assert.ok(text.indexOf('jammed') < text.indexOf('peek()'), 'the change after the content'); + } finally { + await fw.stop(); + await server.close(); + } +}); + test('the puppet stores a structured-only result as 0.27.0 did when its tool change won\'t fit under the cap, and the change goes as a notice', async () => { const { server } = await hintingServer({ content: [], structuredContent: { s: 'x'.repeat(980) } }); const fw = await connectedTo(server, { toolResultInlineMaxChars: 1000 }); From b06275a126d504b261f775cb2b860086e2a04a84 Mon Sep 17 00:00:00 2001 From: Prudence-2217 Date: Mon, 26 Oct 2026 06:11:25 -0400 Subject: [PATCH 43/46] docs(mcpl): the first-sentence exception names close, rendering is said to withstand malformed definitions rather than all, and recover() says what starts the next recovery - README and the fixed fragment: a puppet's open, close or look whose result the store refuses has already counted the definitions it rendered; close's answer marks what it told as open's does. - The fixed fragment: a malformed definition no longer makes rendering throw. "Never throws" claimed more: a value past the runtime's string length, or a notice for more tools at once than a call takes arguments, can still throw. - recover()'s doc: with tools/list failed, a tool the last list lacks stays unknown until a later change or list starts a recovery; nothing in recover() starts one. - The literal-bound test's title also names look's JSON bound, which the test pins. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- changelog.d/mcpl-toolsets-hint-state.fixed.md | 4 ++-- src/mcpl/toolset-host.ts | 3 ++- test/mcpl-tool-hints.test.ts | 2 +- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 3f20e0ad7..102b5fc3a 100644 --- a/README.md +++ b/README.md @@ -498,7 +498,7 @@ while the call is out, the result is dropped and the change with it, and `toolsets--look` shows the state it left. Notices and reminders need the server's `pushEvents` grant, like any delivery; without it, a change that would go as a notice is dropped, and `toolsets--look` shows the state. A new tool's first sentence is told with its -definition until text describing it has reached the model, except that a puppet's open or look whose result +definition until text describing it has reached the model, except that a puppet's open, close or look whose result the store refuses has already counted it. Suggestions are shown, never acted on. Each `ended` id is told once while the host runs (told ids are kept in memory), short of rare interleavings, such as a diff --git a/changelog.d/mcpl-toolsets-hint-state.fixed.md b/changelog.d/mcpl-toolsets-hint-state.fixed.md index 29cb04141..ef65cf36f 100644 --- a/changelog.d/mcpl-toolsets-hint-state.fixed.md +++ b/changelog.d/mcpl-toolsets-hint-state.fixed.md @@ -1,6 +1,6 @@ - MCPL RFC-014 tool presence (`toolHints: true`), fixes to how hint changes are applied and told: - A `tools/list` is applied whole as its last page is read, so a hint read just after it isn't undone, and a malformed or timed-out list changes nothing. A paged list whose pages straddle a change, or a hint or `toolsets/list` entry naming a tool `tools/list` hasn't listed, is recovered from. - - Changes are told in the order they were read, each once: a result's change nobody shows with it is told as a notice before any later change, and a new tool's first sentence counts as shown only once text describing it reached the model. A change a caller took and then couldn't show is told when the caller finds that out, after anything told meanwhile, and a puppet's open or look whose result the store refuses has already counted the definitions it rendered. + - Changes are told in the order they were read, each once: a result's change nobody shows with it is told as a notice before any later change, and a new tool's first sentence counts as shown only once text describing it reached the model. A change a caller took and then couldn't show is told when the caller finds that out, after anything told meanwhile, and a puppet's open, close or look whose result the store refuses has already counted the definitions it rendered. - A change that would go as a notice to a server without the `pushEvents` grant is dropped, and `toolsets--look` shows the state. - A tool the operator's tool policy refuses never starts a recovery, and a tool listed twice is known by its first listing. - - Rendering a server's definitions never throws: a description that isn't a string is left out, and a schema or a toolset's `options` nested very deep is cut short, rather than failing a notice or `toolsets--look`. + - A malformed definition no longer makes rendering throw: a description that isn't a string is left out, and a schema or a toolset's `options` nested very deep is cut short, rather than failing a notice or `toolsets--look`. diff --git a/src/mcpl/toolset-host.ts b/src/mcpl/toolset-host.ts index ec68fcf2c..6f61a28f5 100644 --- a/src/mcpl/toolset-host.ts +++ b/src/mcpl/toolset-host.ts @@ -462,7 +462,8 @@ export class ToolsetController { * Recovering: re-read tools/list (presence, declared definitions), then * toolsets/list (dynamic-tool state in full), so every tool the statement * names is already known, unless tools/list failed: a tool the last list - * lacks then waits for a later recovery. Each applies where it's read. What + * lacks then stays unknown until a later change or list starts a recovery. + * Each applies where it's read. What * changed is told. (stdio and WebSocket deliver in order, so no early rev is * held.) */ diff --git a/test/mcpl-tool-hints.test.ts b/test/mcpl-tool-hints.test.ts index b9ae80361..1d6e7c5b8 100644 --- a/test/mcpl-tool-hints.test.ts +++ b/test/mcpl-tool-hints.test.ts @@ -2167,7 +2167,7 @@ test('a tool whose schema holds a literal nested very deep or a type that isn\'t } finally { await t.done(); } }); -test('a literal is written out up to 65 levels deep and cut to … past that, whatever the runtime\'s stack, and look shows a schema past the bound as too deep', async () => { +test('whatever the runtime\'s stack, a literal is written out up to 65 levels deep and cut to … past that, and look shows a schema\'s JSON whose deepest object is up to 64 levels below it and says too deep past that', async () => { const { t, holding, held } = await holdingServer(); try { holding.add('tools/list'); From 2b022007dbceab03c0ecc268ac3f5b9a0b631b4f Mon Sep 17 00:00:00 2001 From: Octavia-2412 Date: Mon, 26 Oct 2026 06:17:11 -0400 Subject: [PATCH 44/46] fix(mcpl): a structured-only error the host can't write, or whose content is null, reads as 0.27.0 read it, and one whose change won't fit keeps its JSON The commit before showed a structured-only error's content as its JSON through the puppet where a tool change joins it. Three edges of that: - JSON.stringify ran unguarded inside the call's try, so content nested past what the runtime can write (Node 24) turned the stored result into "Maximum call stack size exceeded" and sent the change as a notice. It is now caught, and the error reads as 0.27.0 read it: the server reported an error without saying what went wrong, then the change. A success's JSON view keeps the runtime's error, since its fallback would be an empty success. - A null structuredContent was written as the error text "null". It now reads as an error with no words, as 0.27.0 read it. - When the change didn't fit under the inline cap, the stored error fell back to the no-words text and lost the content. It now keeps the content's JSON, and the change goes as a notice. Texts: - The README says look re-reads each server that presents toolsets; a server named that no longer presents them isn't re-read. - mcplToolFailure's doc and directToolResult's comment no longer say the model's dispatch reads a server's error through mcplToolFailure: only the in-process path does, and the model's dispatch writes a structured-only error as its JSON. Tests, each failing at the commit before on its own assertion: - "the puppet stores a server's error whose structured content the host can't write as 0.27.0 read it, then the tool change" (runs where the runtime's JSON.stringify throws on the nesting; skipped on Node 26); - "the puppet stores a structured-only error's JSON when its tool change won't fit under the cap, and the change goes as a notice"; - "the puppet reads a server's error whose structured content is null as one that said nothing, then the tool change". Co-Authored-By: Claude Opus 5.5 --- README.md | 3 +- src/framework.ts | 28 +++++++-------- test/mcpl-toolsets-wiring.test.ts | 57 +++++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 102b5fc3a..2f46e9448 100644 --- a/README.md +++ b/README.md @@ -457,7 +457,8 @@ framework registers a `toolsets` module with four constant tools: - `toolsets--look {server?, toolset?, instance?, tool?}` restates what is open and what can be opened, or one tool's signature, description, class and input schema. It - re-reads the server first, and says so when it couldn't; what that changed + re-reads each server that presents toolsets first, and says so when it + couldn't; what that changed is in the answer when look restates everything, and otherwise told as a notice; - `toolsets--close {server?, toolset?, instance?}` closes it. diff --git a/src/framework.ts b/src/framework.ts index e7435dfe9..74164189d 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -623,10 +623,9 @@ function mcplResultText(content: McpToolResultContent[] | undefined): string { * An MCPL tools/call result the server marked `isError`, as a failure: its * text is the error, and with none, or only whitespace, as a server's error * answer reads it, the error says the server gave no words and that whether - * the call did anything is unknown. Its other blocks aren't kept. The model's - * dispatch and an in-process caller (`executeToolCall`, - * `ModuleContext.callTool`) both take a server's error from here, so it reads - * the same on each path. `callName` is the tool's name as it was called. + * the call did anything is unknown. Its other blocks aren't kept. An + * in-process caller (`executeToolCall`, `ModuleContext.callTool`) takes a + * server's error from here. `callName` is the tool's name as it was called. */ function mcplToolFailure(content: McpToolResultContent[] | undefined, serverId: string, callName: string): ToolResult { const said = mcplResultText(content); @@ -22626,18 +22625,17 @@ export class AgentFramework { return joined ?? alone; }; // A server's error is a failure here too, as the model's dispatch - // reads it: its text (mcplToolFailure), without its other blocks, and - // its structured content, kept by presence; where a change joins an - // error that is structured content alone, that content's JSON, as below. - // A success is the server's own blocks and structured content, for the - // caller to use in process. + // reads it: its text, without its other blocks, and its structured + // content, kept by presence. A success is the server's own blocks and + // structured content, for the caller to use in process. const direct = AgentFramework.directToolResult(result, serverId, call.name); if (direct.isError) { const { data: blocks, ...failure } = direct; - const shown: ToolResult = taken && !(Array.isArray(blocks) && blocks.length) && Object.prototype.hasOwnProperty.call(direct, 'structured') - ? { ...failure, error: JSON.stringify(direct.structured) ?? 'null' } - : failure; - return withChange(shown, failure); + let shown: ToolResult = failure; + if (taken && !(Array.isArray(blocks) && blocks.length) && direct.structured != null) { + try { shown = { ...failure, error: JSON.stringify(direct.structured) }; } catch { /* past what the runtime's JSON.stringify can write */ } + } + return withChange(shown); } // RFC-005 §6.2: register an MCPL peer's references with this server, so // a stub later made of them is bound and fetch_reference can fetch @@ -30751,8 +30749,8 @@ export class AgentFramework { ? { structured: result.structuredContent } : {}; if (result.isError) { - // Its text as the model's dispatch reads it (mcplToolFailure). The raw - // content rides along for a script's structured result, the server's + // Its text (mcplToolFailure). The raw content rides along for a + // script's structured result, the server's // own account of the error; an in-process caller gets the text alone // (executeMcplToolCall). const content = Array.isArray(result.content) ? result.content as McpToolResultContent[] : undefined; diff --git a/test/mcpl-toolsets-wiring.test.ts b/test/mcpl-toolsets-wiring.test.ts index 807a5ce17..50e40d5b9 100644 --- a/test/mcpl-toolsets-wiring.test.ts +++ b/test/mcpl-toolsets-wiring.test.ts @@ -242,6 +242,35 @@ test('the puppet stores a server\'s error that is structured content alone as th } }); +test('the puppet stores a structured-only error\'s JSON when its tool change won\'t fit under the cap, and the change goes as a notice', async () => { + const { server } = await hintingServer({ content: [], structuredContent: { s: 'x'.repeat(980) }, isError: true }); + const fw = await connectedTo(server, { toolResultInlineMaxChars: 1000 }); + try { + const { toolUseId } = await fw.framework.puppetToolCall('scout', 'arc--status', {}); + const text = storedResult(fw, toolUseId); + assert.match(text, /x{980}/); + assert.doesNotMatch(text, /peek\(\)/); + await waitFor(() => notices(fw).some((x) => x.includes('peek()')), 'the change told as a notice'); + } finally { + await fw.stop(); + await server.close(); + } +}); + +test('the puppet reads a server\'s error whose structured content is null as one that said nothing, then the tool change', async () => { + const { server } = await hintingServer({ content: [], structuredContent: null, isError: true }); + const fw = await connectedTo(server); + try { + const { toolUseId } = await fw.framework.puppetToolCall('scout', 'arc--status', {}); + const text = storedResult(fw, toolUseId); + assert.match(text, /without saying what went wrong/); + assert.ok(text.indexOf('without saying') < text.indexOf('peek()'), 'the change after the error'); + } finally { + await fw.stop(); + await server.close(); + } +}); + test('the puppet stores a structured-only result as 0.27.0 did when its tool change won\'t fit under the cap, and the change goes as a notice', async () => { const { server } = await hintingServer({ content: [], structuredContent: { s: 'x'.repeat(980) } }); const fw = await connectedTo(server, { toolResultInlineMaxChars: 1000 }); @@ -386,6 +415,34 @@ test('a model\'s call whose result the host can\'t read still tells the tool cha } }); +test('the puppet stores a server\'s error whose structured content the host can\'t write as 0.27.0 read it, then the tool change', { skip: !stringifyOverflows && 'this runtime writes the nesting out, so the host reads the content' }, async () => { + let rev = 0; + const server = await startScriptedServer({ + advert: { ...ADVERT, pushEvents: true, featureSets: { ...(ADVERT.featureSets as Json), ...EVENTS } }, + tools: [STATUS, PEEK], + listRev: () => rev, + on: (msg, peer) => { + if (msg.method === 'toolsets/list') { peer.reply(msg.id, { open: [], _meta: { 'mcpl/hints': { rev: ++rev, tools: [] } } }); return; } + if (msg.method === 'tools/call') { + const meta = JSON.stringify({ 'mcpl/hints': { rev: ++rev, tools: [{ name: 'peek', visible: true }] } }); + peer.ws.send(`{"jsonrpc":"2.0","id":${JSON.stringify(msg.id)},"result":{"content":[],"structuredContent":${nestedPastStringify},"isError":true,"_meta":${meta}}}`); + return; + } + if (msg.id !== undefined) peer.error(msg.id, -32601, 'Method not found'); + }, + }); + const fw = await connectedTo(server); + try { + const { toolUseId } = await fw.framework.puppetToolCall('scout', 'arc--status', {}); + const text = storedResult(fw, toolUseId); + assert.match(text, /reported an error for arc--status without saying what went wrong/); + assert.ok(text.indexOf('without saying') < text.indexOf('peek()'), 'the change after the error'); + } finally { + await fw.stop(); + await server.close(); + } +}); + test('a tool change the host can\'t tell for want of an agent is logged, as one it can\'t tell for want of pushEvents is', async () => { const { server, showPeek } = await hintingServer({ content: [] }); const fw = await connectedTo(server, { agents: [] }); From 3fdc42e10f96f32a5fc3475c5f0d60a8a1a26f0f Mon Sep 17 00:00:00 2001 From: Octavia-2412 Date: Mon, 26 Oct 2026 06:20:06 -0400 Subject: [PATCH 45/46] docs(mcpl): the error branch's comment no longer says the model's dispatch reads an error the same way For a structured-only error with no change taken, the in-process path gives the no-words text where the model's dispatch writes the content's JSON, so "as the model's dispatch reads it" was false. The clause is cut. Co-Authored-By: Claude Opus 5.5 --- src/framework.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index 74164189d..bd6367242 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -22624,10 +22624,10 @@ export class AgentFramework { taken = undefined; return joined ?? alone; }; - // A server's error is a failure here too, as the model's dispatch - // reads it: its text, without its other blocks, and its structured - // content, kept by presence. A success is the server's own blocks and - // structured content, for the caller to use in process. + // A server's error is a failure here too: its text, without its other + // blocks, and its structured content, kept by presence. A success is + // the server's own blocks and structured content, for the caller to use + // in process. const direct = AgentFramework.directToolResult(result, serverId, call.name); if (direct.isError) { const { data: blocks, ...failure } = direct; From 8f71c3bbf5a2bb267af328de0ad7277df96ead2f Mon Sep 17 00:00:00 2001 From: Prudence-2217 Date: Mon, 26 Oct 2026 06:33:52 -0400 Subject: [PATCH 46/46] docs(mcpl): limits no test pins are stated as read from the code - README: a server with no toolsets presenting none once its latest tools/list has no dynamic tool, so act takes its last one only with `server` named until the rebuild, is by reading of the code. - README and the wiring fragment: a model's call whose turn is stopped while it is out has its result, and the change with it, dropped, as at 0.27.0; a run at 0.27.0 and one at 03d1fe13, with pushEvents granted and not, show it. The same when the agent is gone rests on reading alone, and says so. - README and the hint-state fragment: a puppet's open, close or look whose result the store refuses has already counted the definitions it rendered, by reading of the code. The test of a refused look pins only that its change is told. Co-Authored-By: Claude Opus 5.5 --- README.md | 12 ++++++------ changelog.d/mcpl-toolsets-hint-state.fixed.md | 2 +- changelog.d/mcpl-toolsets-wiring.fixed.md | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 2f46e9448..309e543d7 100644 --- a/README.md +++ b/README.md @@ -463,8 +463,8 @@ framework registers a `toolsets` module with four constant tools: notice; - `toolsets--close {server?, toolset?, instance?}` closes it. -`server` can be left out when only one server has the name. A server with no -toolsets stops presenting them once its latest `tools/list` has no dynamic +`server` can be left out when only one server has the name. By our reading of +the code, a server with no toolsets stops presenting them once its latest `tools/list` has no dynamic tool, so until the rebuild, act takes its last one only with `server` named. Hint changes apply as their message is read, in `rev` order, whatever becomes of the message (a @@ -494,13 +494,13 @@ result, when a later change goes to the agent before the call's caller has taken it, so changes are told in the order they were read (the agent's window can still show a call's result, a push's event, or a change its taker couldn't show after a change read later). The model's own call's change counts as told -once its result is handed back: if the turn is stopped, or the agent is gone, -while the call is out, the result is dropped and the change with it, and +once its result is handed back: if the turn is stopped while the call is out +(or, by our reading of the code, the agent is gone), the result is dropped and the change with it, and `toolsets--look` shows the state it left. Notices and reminders need the server's `pushEvents` grant, like any delivery; without it, a change that would go as a notice is dropped, and `toolsets--look` shows the state. A new tool's first sentence is told with its -definition until text describing it has reached the model, except that a puppet's open, close or look whose result -the store refuses has already counted it. Suggestions are shown, never acted on. Each +definition until text describing it has reached the model, except that, by our reading of the code, a puppet's +open, close or look whose result the store refuses has already counted it. Suggestions are shown, never acted on. Each `ended` id is told once while the host runs (told ids are kept in memory), short of rare interleavings, such as a `toolsets--look` overlapping other reads of the same server, that can tell one diff --git a/changelog.d/mcpl-toolsets-hint-state.fixed.md b/changelog.d/mcpl-toolsets-hint-state.fixed.md index ef65cf36f..412272196 100644 --- a/changelog.d/mcpl-toolsets-hint-state.fixed.md +++ b/changelog.d/mcpl-toolsets-hint-state.fixed.md @@ -1,6 +1,6 @@ - MCPL RFC-014 tool presence (`toolHints: true`), fixes to how hint changes are applied and told: - A `tools/list` is applied whole as its last page is read, so a hint read just after it isn't undone, and a malformed or timed-out list changes nothing. A paged list whose pages straddle a change, or a hint or `toolsets/list` entry naming a tool `tools/list` hasn't listed, is recovered from. - - Changes are told in the order they were read, each once: a result's change nobody shows with it is told as a notice before any later change, and a new tool's first sentence counts as shown only once text describing it reached the model. A change a caller took and then couldn't show is told when the caller finds that out, after anything told meanwhile, and a puppet's open, close or look whose result the store refuses has already counted the definitions it rendered. + - Changes are told in the order they were read, each once: a result's change nobody shows with it is told as a notice before any later change, and a new tool's first sentence counts as shown only once text describing it reached the model. A change a caller took and then couldn't show is told when the caller finds that out, after anything told meanwhile. By our reading of the code, a puppet's open, close or look whose result the store refuses has already counted the definitions it rendered. - A change that would go as a notice to a server without the `pushEvents` grant is dropped, and `toolsets--look` shows the state. - A tool the operator's tool policy refuses never starts a recovery, and a tool listed twice is known by its first listing. - A malformed definition no longer makes rendering throw: a description that isn't a string is left out, and a schema or a toolset's `options` nested very deep is cut short, rather than failing a notice or `toolsets--look`. diff --git a/changelog.d/mcpl-toolsets-wiring.fixed.md b/changelog.d/mcpl-toolsets-wiring.fixed.md index 6767e381a..527007314 100644 --- a/changelog.d/mcpl-toolsets-wiring.fixed.md +++ b/changelog.d/mcpl-toolsets-wiring.fixed.md @@ -1,5 +1,5 @@ - MCPL RFC-014 tool presence (`toolHints: true`), fixes to how the toolsets host is wired into the framework: - - A result's tool change goes with it only where the result reaches an agent: the model's own call, and the operator's puppet, whose call and result are stored in the agent's window. The model's own call counts as reaching it once its result is handed back. If the turn is stopped, or the agent is gone, while the call is out, the result is dropped, and the change with it, counted as told and not told again, as in 0.27.0; `toolsets--look` shows the state it left. A script's, a module's or a host's own call leaves it to a notice. A dynamic tool's change that wouldn't fit whole within its result's inline cap, or whose call fails after its result was read, goes as a notice too. A result that is structured content alone shows that content, as its JSON, before the change that goes with it. + - A result's tool change goes with it only where the result reaches an agent: the model's own call, and the operator's puppet, whose call and result are stored in the agent's window. The model's own call counts as reaching it once its result is handed back. If the turn is stopped while the call is out (or, by our reading of the code, the agent is gone), the result is dropped, and the change with it, counted as told and not told again, as in 0.27.0; `toolsets--look` shows the state it left. A script's, a module's or a host's own call leaves it to a notice. A dynamic tool's change that wouldn't fit whole within its result's inline cap, or whose call fails after its result was read, goes as a notice too. A result that is structured content alone shows that content, as its JSON, before the change that goes with it. - An open or close whose outcome is unknown is reported as unconfirmed rather than as not done, and names `toolsets--look`, which says when it couldn't ask the server. A selector that is present but isn't a name is refused (null reads as left out), and a refusal for a name the server doesn't have says what it has. - Removing a server, or restarting it under another id or with `toolHints` off, leaves no toolset state behind. A stop or a disconnect while a `toolHints` server is still connecting ends the connect. - The capability mask no longer reports `toolHints`, `toolsets` and `surface` as masked: `toolHints: false` turns hints off. Operator tool-name patterns naming a dynamic tool count as matched.