diff --git a/README.md b/README.md index 4f29c39c6..309e543d7 100644 --- a/README.md +++ b/README.md @@ -448,36 +448,75 @@ 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 signature, description, class and + input schema. It + 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. -`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. 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 -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, 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 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. +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 +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 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 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, 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 +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 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 -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. +`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), 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/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..fc7568503 --- /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 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, 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. 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..412272196 --- /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 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.changed.md b/changelog.d/mcpl-toolsets-wiring.changed.md new file mode 100644 index 000000000..7fd20367e --- /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 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. diff --git a/changelog.d/mcpl-toolsets-wiring.fixed.md b/changelog.d/mcpl-toolsets-wiring.fixed.md new file mode 100644 index 000000000..527007314 --- /dev/null +++ b/changelog.d/mcpl-toolsets-wiring.fixed.md @@ -0,0 +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. 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. + - 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 61d94c88f..bd6367242 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 @@ -615,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); @@ -758,8 +765,9 @@ 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 { ToolsetsModule, resolveAct } from './mcpl/toolsets-module.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'; import { MALFORMED_CONTENT_NOTICE, normalizeStandardToolResult } from './mcpl/tool-result-normalize.js'; @@ -22322,6 +22330,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 @@ -22377,6 +22388,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', @@ -22393,6 +22405,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 @@ -22411,14 +22428,15 @@ export class AgentFramework { private async executeToolCallFrom(given: ToolCall, origin: ChannelToolOrigin): Promise { const call = asCalled(given); + // 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); } const marked = markedInputRefusal(call); if (marked !== undefined) { @@ -22439,17 +22457,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]); @@ -22504,6 +22522,21 @@ 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 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); + 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. */ @@ -22529,7 +22562,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), @@ -22545,23 +22578,13 @@ 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)?.changeText(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 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 { if (!this.mcplServerRegistry) { return { success: false, error: 'MCPL not initialized, so the call didn\'t run', isError: true }; @@ -22582,18 +22605,37 @@ 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); - // 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. + 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, 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 ?? alone; + }; + // 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; - return failure; + const { data: blocks, ...failure } = direct; + 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 @@ -22608,18 +22650,36 @@ 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; - } - return direct; + const blocks = Array.isArray(direct.data) ? direct.data : []; + 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' } + : read; + return withChange(shown, read); + } + return withChange(direct); } catch (err) { + 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, }; } } + private withWholeChange(agentName: string, call: ToolCall, result: ToolResult, change: string, maxChars: number | undefined): ToolResult | null { + const joined = withChangeText(result, change); + 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 { + 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) // --------------------------------------------------------------------------- @@ -24078,8 +24138,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 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 @@ -27796,10 +27857,18 @@ 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'), 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'; + }, })); } @@ -27813,7 +27882,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 @@ -27836,21 +27905,26 @@ 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; + 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; 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; } } @@ -28010,11 +28084,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 @@ -28745,7 +28819,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'); } @@ -28798,6 +28872,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 @@ -28907,7 +28982,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) { @@ -29394,30 +29469,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) @@ -29900,6 +29983,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); @@ -29917,11 +30001,14 @@ 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). The agent is told where each tool + // 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 moves = presence?.presence.active ? presence.applyMoves() : []; - if (moves.length > 0) this.recordToolsetNotice(server.id, moves.join('\n'), { invite: false }); + 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; @@ -30052,6 +30139,10 @@ export class AgentFramework { this.mcplToolClasses = toolClasses; this.mcplToolServers = toolServers; this.mcplListedServers = listedServers; + 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(', '); @@ -30059,7 +30150,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 } }, ); @@ -30068,7 +30159,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 @@ -30117,7 +30210,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); @@ -30141,9 +30234,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); } @@ -30300,7 +30394,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. @@ -30317,7 +30416,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 }, }); }; @@ -30477,10 +30576,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 @@ -30512,7 +30614,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; @@ -30618,6 +30727,8 @@ export class AgentFramework { moduleName: `mcpl:${serverId}`, result: toolResult, }); + if (taken) taken.toolsets.told(taken.cs); + taken = undefined; }) .catch(failDispatch); } @@ -30638,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/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/server-connection.ts b/src/mcpl/server-connection.ts index d80e0b730..3c8656ce9 100644 --- a/src/mcpl/server-connection.ts +++ b/src/mcpl/server-connection.ts @@ -240,19 +240,29 @@ export function toolsetDeclarationsOf(advertised: unknown): Record; result: unknown; awaited: boolean } + /** A response to a request that can carry hints (RFC-014 §6.1 carrier 1), + * or a `tools/list`'s whole inventory (§4.1), its pages joined by + * sendToolsList, `straddled` when its pages carry different revs. + * `awaited`: whoever sent it still waits for it; false for a + * result that arrives after its timeout. A JSON-RPC error carries no state + * and isn't tapped. */ + | { 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 } /** 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']); + +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 @@ -330,6 +340,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 +350,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,15 +466,18 @@ 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; /** 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 { @@ -1708,22 +1725,40 @@ 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. It's `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 }; + 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 } : {}), + 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)) { 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. @@ -2176,7 +2211,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(() => { @@ -2218,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 { @@ -2654,6 +2689,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..5f97799a6 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,20 @@ 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 }>; + /** 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: [] }); -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; +/** 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 : []); @@ -90,7 +95,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 +122,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 once a rebuild has published them. */ private pendingMoves = new Map(); - private moved = new Map(); /** Digests of definitions the model has been shown with their first sentence. */ readonly shown = new Set(); @@ -132,6 +137,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 ── @@ -166,6 +173,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 +198,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 +256,23 @@ 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); + } + + /** RFC §4.2: "Until it applies the move, the tool keeps working where it + * 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; + } + + 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 +365,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 +385,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 +400,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 +422,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 +479,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 +497,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 +512,51 @@ 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 (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. */ + 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,15 +571,15 @@ export class ToolPresence { // ── boundaries ── - /** Apply pending static↔dynamic moves (RFC §4.2) at a boundary the host chooses. Returns what moved. */ - 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 }); - } - 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; } @@ -550,14 +607,22 @@ 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). `describeAll`: every tool with its + * first sentence, as for a result that may reach no agent. + */ + 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[] = []; for (const c of cs.changes) { const bare = opts.bare === c.key; @@ -566,8 +631,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 })); + 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) { @@ -576,7 +640,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'); } @@ -605,14 +669,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.`); } @@ -639,7 +705,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/tool-signature.ts b/src/mcpl/tool-signature.ts index ec3d3db99..aec354f7c 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. @@ -24,11 +24,25 @@ 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 { + return nestsWithin(value) ? JSON.stringify(value) ?? 'null' : '…'; +} + 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; @@ -38,10 +52,12 @@ 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); + // 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) { case 'integer': case 'number': { @@ -81,7 +97,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(', ')})`; @@ -94,22 +110,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 > DEEPEST) 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 +138,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,15 +157,26 @@ 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, or too long, to show. */ +export function jsonText(value: unknown, space?: number): string { + 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. */ 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:', - 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..6f61a28f5 100644 --- a/src/mcpl/toolset-host.ts +++ b/src/mcpl/toolset-host.ts @@ -5,10 +5,14 @@ * 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; 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 + * 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. @@ -20,12 +24,14 @@ * 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 { redactServerText } from './protocol-family.js'; +import { logDetail, logValueRedacted } from './log-value.js'; import { - ToolPresence, emptyChange, isEmptyChange, joinChanges, keyOf, - type ChangeSet, type MetaToolNames, + ToolPresence, emptyChange, isEmptyChange, keyOf, + type ChangeSet, type MetaToolNames, type Presence, } from './tool-presence.js'; import type { HintedTool } from './tool-signature.js'; @@ -38,7 +44,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,16 +60,24 @@ 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; /** The operator's log. */ 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 - * 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 +85,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; @@ -95,6 +103,7 @@ export class ToolsetController { META_TOOL_NAMES, (tool) => isToolAllowed(tool, this.config), (tool) => deps.listedName(serverId, tool), + (tool) => deps.published?.(serverId, tool), ); } @@ -102,6 +111,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; @@ -118,6 +135,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; @@ -139,7 +160,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; } @@ -153,7 +174,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); } @@ -163,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; @@ -178,7 +198,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; } @@ -186,32 +206,29 @@ 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); 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 { + private readResult(method: string, params: Record, res: Record, awaited: boolean, straddled: 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 (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(); + } + this.deliver(cs); return; } case 'toolsets/list': @@ -259,9 +276,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(); @@ -271,6 +288,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; @@ -281,7 +302,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()); @@ -290,37 +311,76 @@ 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.presence.markTold(cs.ended); + this.presence.markShown(cs); + return 'told'; } /** * 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 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.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)}.`; } @@ -332,35 +392,56 @@ 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, or an empty one; taken as changeText's is. */ - changeOf(carrier: unknown): ChangeSet { + /** + * 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 { 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(); } + /** 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); + } + + /** 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); + } + + private changeOf(carrier: object): ChangeSet { + return this.readChanges.get(carrier) ?? emptyChange(); + } + // ── recovery (RFC §6.1) ── private scheduleRecovery(): void { @@ -380,19 +461,23 @@ 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 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.) */ private async recover(): Promise { const connection = this.connection; if (!connection || !connection.isConnected) return; this.recovering = true; try { - const tools = await this.ownToolList(connection); + await this.ownToolList(connection); + // 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; - this.deliver(joinChanges(tools, lists)); + this.deliver(lists); } finally { this.recovering = false; if (this.recoverAgain) { @@ -402,29 +487,21 @@ export class ToolsetController { } } - private async ownToolList(connection: McplServerConnection): Promise { - if (this.ownLists++ === 0) this.ownListChange = null; + /** The tap applies the list and tells its change as it's read. */ + 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(); } catch (error) { - this.log(`tools/list failed: ${error instanceof Error ? error.message : String(error)}`); - } finally { - this.ownLists--; + this.log(`tools/list failed: ${this.failure(error)}`); } - 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.takeChange(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(); } } @@ -441,35 +518,41 @@ 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 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 { - if (!this.presence.active) return []; + async refreshOpen(opts: { shown: boolean }): 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; + if (!isObj(r)) return null; + return opts.shown ? this.takeChange(r) : this.changeOf(r); } catch (error) { - this.log(`toolsets/list (look) failed: ${error instanceof Error ? error.message : String(error)}`); - return []; + this.log(`toolsets/list (look) failed: ${this.failure(error)}`); + return null; } } // ── 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(); @@ -477,24 +560,24 @@ 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 { key, ...(title ? { title } : {}), content: Array.isArray(res.content) ? res.content : [], - change: this.changeOf(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.changeOf(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. */ @@ -518,7 +601,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; } @@ -532,11 +615,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 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 (!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); } } @@ -565,8 +652,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); } @@ -615,7 +705,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..8a82ef940 100644 --- a/src/mcpl/toolsets-module.ts +++ b/src/mcpl/toolsets-module.ts @@ -26,8 +26,9 @@ 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 { checkArgs, renderToolDefinition, type HintedTool } from './tool-signature.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'; /** The namespace a reminder's stored injection carries. */ export const TOOLSETS_REMINDER_NAMESPACE = 'mcpl-toolsets'; @@ -55,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 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. toolsets--look shows what is available now.', inputSchema: { type: 'object', properties: { @@ -84,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: { @@ -97,6 +98,33 @@ 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`); + } +} + +/** 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, or says it couldn't.`; +} + +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}`)); +} + +/** 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 }; @@ -142,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; } @@ -158,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. */ @@ -171,24 +205,33 @@ 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 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). */ +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 : {}; + 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; 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); @@ -202,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; ` + @@ -218,7 +261,13 @@ 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). */ + constructor( + private readonly hub: ToolsetHub, + private readonly takerFor: (call: ToolCall) => ((taken: TakenChange) => void) | null, + ) {} async start(_ctx: ModuleContext): Promise {} async stop(): Promise {} @@ -241,11 +290,13 @@ export class ToolsetsModule implements Module { async handleToolCall(call: ToolCall): Promise { 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) { @@ -261,13 +312,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}"`) @@ -286,23 +338,24 @@ 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 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[] = [ { 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') }); + take?.({ toolsets: c, cs: opened.change }); return blocksResult(blocks); } if (p.undeclaredGroups.includes(toolset)) { @@ -310,13 +363,15 @@ 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(', ')}` : ''}`); + return fail(noToolset(c, toolset)); } /** @@ -333,15 +388,43 @@ 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 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 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), shown); + } catch (error) { + giveBack(); + if (error instanceof MetaError && unread.length) fail([error.message, ...unread].join('\n')); + throw error; + } + if (result.isError || typeof result.data !== 'string') { + giveBack(); + return result; + } + 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]; + // 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; + }) + : []; + 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); @@ -352,23 +435,27 @@ 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)); 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')); @@ -380,7 +467,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')); } @@ -388,20 +475,22 @@ 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]; + // The definition act would check, wherever act would take the tool. + 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)}`); } - 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}"`) @@ -432,20 +521,25 @@ export class ToolsetsModule implements Module { } let closed: Awaited>; try { - closed = await c.close(toolset!, instance); + closed = await c.close(toolset!, instance, { shown }); } 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.`; + 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}"`); + return fail(noToolset(c, toolset!)); } } 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/helpers/toolset-servers.ts b/test/helpers/toolset-servers.ts index cbf849c75..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'; @@ -76,6 +77,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 +108,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; @@ -166,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({ @@ -176,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-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-tool-hints.test.ts b/test/mcpl-tool-hints.test.ts index ea18ce966..1d6e7c5b8 100644 --- a/test/mcpl-tool-hints.test.ts +++ b/test/mcpl-tool-hints.test.ts @@ -10,10 +10,15 @@ 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, } 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: {} } }, @@ -66,6 +71,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 +83,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 +93,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, @@ -94,16 +106,18 @@ 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 { - 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'); + 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 })), + '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); @@ -115,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 () => { @@ -128,13 +145,13 @@ 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 settle(400); - assert.match(await look(t.fw), /peek\(\)/); + await waitFor(() => windowText(t.fw.framework).some((x) => x.includes('tool changes') && x.includes('peek()')), 'the next change applied, and told'); + 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(); } }); @@ -157,7 +174,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 +196,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 +206,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 +232,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 +287,15 @@ 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'); + 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(); } }); @@ -295,6 +316,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)); + 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'); + } finally { await t.done(); } +}); + test('vector 30: a duplicate eventId\'s hints are ignored', async () => { const t = await setup(); try { @@ -329,6 +371,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]!]; @@ -366,9 +447,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,8 +559,8 @@ 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); - assert.ok(!windowText(fw.framework).some((t) => t.includes('tool changes')), 'told once, in the result'); + await nextMacrotask(); + 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(); @@ -489,8 +569,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 +579,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(); } @@ -521,6 +603,161 @@ 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('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), /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.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) => { + 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), /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' }, [/"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, facts] of refusals) { + const r = await callTool(t.fw.framework, name, input); + assert.ok(r.isError, `${name} ${JSON.stringify(input)} is refused`); + for (const fact of facts) assert.match(resultText(r), fact, `${name} ${JSON.stringify(input)}`); + } + } 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), /called directly, by name/); + } + const named = await callTool(none.fw.framework, 'toolsets--open', { server: 'arc', toolset: 'game' }); + assert.ok(named.isError); + assert.match(resultText(named), /arc sends no tool hints/); + assert.match(resultText(named), /called directly, by name/); + } 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\\"\}/, 'naming look'); + } 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"\}/, 'naming look'); + } 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. @@ -540,3 +777,1437 @@ 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'] }); + 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\./, 'a host\'s own look shows it to its caller'); + assert.ok(!notices(t.fw).length, 'without pushEvents no notice was written'); + 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(); } +}); + +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 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); + 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(() => 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'); + } 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('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); // 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(); } +}); + +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) { + const told: string[] = []; + const hub = new ToolsetHub({ + notify: (_s: string, text: string) => { if (!writes()) return false; told.push(text); return true; }, + 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 = () => {}; + 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: 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 }); + return { hub, c, told, read, callResult, readCall }; +} + +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 }]); + readCall(later); + const taken = c.takeChange(later); + assert.equal(told.length, 1, 'the earlier change, told first'); + assert.match(told[0]!, /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 () => { + 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); // 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(); } +}); + +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; + peer.error(msg.id, -32000, 'list unavailable'); + return true; + }); + 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 + const order = t.server.received.slice(from).map((m) => m.method).filter((m) => m === 'tools/list' || m === 'toolsets/list'); + assert.deepEqual(order, ['tools/list', 'toolsets/list'], 'tools/list first (RFC-014 §6.1), and no further recovery'); + } 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; + }); + 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[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, 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(); } +}); + +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); // 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(); } +}); + +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 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 () => { + 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('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(() => 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(); } +}); + +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 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); // 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(); } +}); + + +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 }] } }; + read({ kind: 'push', params }); + await nextMacrotask(); + writes = true; + assert.match(c.presence.render(c.takeChange(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); // 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(); } +}); + +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(); + 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 () => { + 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, /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.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(); } +}); + +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(); } +}); + +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(); } +}); + +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(() => 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?.() }; +} + +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 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 === 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'); + } finally { + release(); + await t.done(); + } +}); + +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 { + 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 { + 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'); + 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(); + } +}); + +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.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 { + 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; + 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; }; + 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(); + // 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 === false, '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 at the next refresh', 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'), '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('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(); + } +}); + +/** 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); + 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('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'); + 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, 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; } + return false; + }); + 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 [manifests, lists] = [count('mcpl/manifest'), count('tools/list')]; + advert = next; + 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 { + 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); + 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(); } +}); + + +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 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'); + 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":["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 =…, d\?: any, e\?: string\) — Fixed\./); + const peek = await look(t.fw, { server: 'arc', tool: 'peek' }); + 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('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'); + 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}},` + + `{"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(); } +}); + +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(); } +}); diff --git a/test/mcpl-toolsets-e2e.test.ts b/test/mcpl-toolsets-e2e.test.ts index 10d1fc107..f387992aa 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'; @@ -90,15 +89,16 @@ 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.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"}']); @@ -144,30 +144,38 @@ 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, 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/); - // 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 new file mode 100644 index 000000000..50e40d5b9 --- /dev/null +++ b/test/mcpl-toolsets-wiring.test.ts @@ -0,0 +1,827 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +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'; +import type { FrameworkConfig } from '../src/types/framework.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(); + } +}); + +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 = {}, 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; +} + +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}`); + 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:')); +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('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('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); + 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 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 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 }); + 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); + 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 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); + 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(); + } +}); + +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 = (() => { + 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 () => { + 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},"_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('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: [] }); + 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'); + 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(); + 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 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 { + 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(); + } +}); + +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(); + } +}); + +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(); + } +}); + +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(); + } +}); + +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(); + } +}); + +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' }, { type: 'text', text: 'twice' }], 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 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 } } }, + ]); + 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'); + } 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(); + } +});