Repository navigation
feat(mcpl): RFC-014 tool presence and toolsets, host side (opt-in toolHints) - #799
Conversation
…lHints) A server configured with `toolHints: true` is offered `experimental.mcpl.toolHints`; its hints count only if it advertised `toolHints` too. Off (the default), nothing changes. - Static tools stay in the API tool list; dynamic tools never enter it. - A `toolsets` module with four constant meta-tools: toolsets--open, toolsets--act (instance in `_meta["mcpl/instance"]`, args checked against the instance's current definition, closed undeclared groups gated), toolsets--look and toolsets--close. - Hint changes apply in rev order as each message is read, through a new read tap on McplServerConnection that also sees results after their timeout and pushes before admission. A skipped rev recovers through tools/list, then toolsets/list, which is also read after every (re)connect. Per-instance visibility and definitions; ended ids told once; duplicate eventIds ignored; manifest removals close instances. - Changes are announced in the call's result, after the push that explains them, or as `mcpl-toolsets` notices; suggestions are shown, never acted on. - Reminders: Agent.onCompiledToSend lets the host see which open toolsets' announcements a request carries; when one has left an agent's context, the module's gatherContext stores a reminder at its next start. RFC-015 (delegated access) is deliberately not included. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ork shim agent-framework's RFC-014 branch (anima-research/agent-framework#799) declares McplServerConfig.toolHints as boolean, which is what the recipe and mcpl-servers.json fields already hold. The host hands agent-framework each server's config untyped, so it needs nothing of agent-framework's type for the field to pass through, and the shim goes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Automated review
Recommendation: merge and fast-follow. 5 medium severity findings can be fixed after the merge.
Reviewed commit b9a7bc8 with claude-opus-5-5, by reading the code. It did not run the code or look at the checks.
Does this change belong in the project?
It fits the project. This change fits. It implements another MCPL RFC on the host side, in src/mcpl/, which is what the project already does for RFC-005, RFC-006 and RFC-007.
6 reasons with sources
It is opt-in per server and leaves servers without toolHints unchanged.
- Host-side MCPL protocol work is the stated purpose of the project's MCPL component, and RFC extensions already ship there. The README describes an optional host-side implementation of MCP Live and documents earlier RFCs (RFC-006 coalescing, RFC-007 patterns) as per-server features. RFC-014 tool presence and toolsets is the same kind of addition, and it gets its own README subsection in the same place. (README.md, "MCPL (MCP Live)" and "Event coalescing (RFC-006)")
- New code sits in
src/mcpl/beside the existing MCPL host modules, and the framework wires it in the same way. The new files are tool-presence.ts, tool-signature.ts, toolset-host.ts and toolsets-module.ts. They match the existing pattern of one concern per file under src/mcpl. The meta-tools are a module that uses the existinggatherContextand stored-injection path rather than a new mechanism. (src/mcpl/ layout (tool-policy.ts, tool-lifecycle.ts, push-coalescer.ts, …)) - It is off by default and leaves servers without the flag unchanged, consistent with how other MCPL options are added.
toolHintsdefaults to false. The README rule that MCPL-only settings are refused on modern servers is extended to covertoolHints: true, which matches how the existing settings are treated. (README.md, "Modern MCP servers" config rules) - The new dependency is dev-only, but the change adds one public hook to
Agent.@animalabs/mcpl-coreis pinned as a devDependency for the end-to-end tests, so it costs nothing at runtime.Agent.onCompiledToSendis a new optional public callback that maintainers will have to keep. It is small and documented, and the reminder feature needs it. (package.json; src/agent.ts) toolsets--actreaches servers through a module rather than the usual MCPL tool-dispatch path, so this part does not yet follow the existing dispatch design. The code review found thatactskips the dispatch path's state exchange and its RFC-005 result handling. That is a defect for the code review to settle, but it means this part should not be counted as following the established design until it uses that path. (README.md, MCPL tool names and dispatch)- The PR has the body sections, changelog fragment, numeric test baseline and tests that CONTRIBUTING asks for, and it declares what it leaves out. It adds changelog.d/mcpl-toolsets-host.added.md and reports test counts against main with failures unchanged. It lists projection, surface mode and RFC-015 as out of scope, so the change stays one coherent feature. (CONTRIBUTING.md, "What a PR should contain" and "Changelog")
Findings
5 findings of medium or high severity, see the inline comments:
- Medium severity. Host-side argument check runs a server-supplied regex on the event loop with no bound.
- Medium severity. toolsets--act skips the MCPL dispatch path's state exchange and RFC-005 result handling.
- Medium severity. Hint changes on results of the framework's own tools/call requests are applied but never told.
- Medium severity. toolsets--close sends an instance for a toolset that has no instances.
- Medium severity, test coverage. Vector 9 test cannot detect a re-open after reconnect.
7 more findings, less severe
- Low severity, test coverage,
src/framework.ts:29981-29995. No test covers refusing a dynamic tool on the model's dispatch path. The refusal of a dynamic tool called by its prefixed name is tested only through executeToolCall (executeMcplToolCall, e2e 'a dynamic tool called by a namespaced name is refused'). The parallel check added in dispatchMcplToolCall has its own trace and pushEvent of a refused tool-result, and no test covers it. That is the path a model's tool_use takes. A scripted membrane response callingarc--playshould be checked for the refusal result. - Low severity, test coverage,
src/mcpl/toolset-host.ts:176-182. No test for a push carrying both hints and coalesce. The description says that a push's hints are ignored when it also carriescoalesce(§7), and that this triggers a recovery. Neither test file sends a push with both. A test should check that the hints don't apply and that tools/list then toolsets/list are read. - Low severity, test coverage,
src/mcpl/toolset-host.ts:494-514. No test for a manifest change that withdraws toolHints. manifestChanged has a separate branch for a server that withdraws toolHints: it deactivates presence, adds a note and is meant to put every tool back in the list through handleToolsListChanged. Vector 33 only covers removing a toolset. No test checks that the dynamic tools then appear as staticprefix--toolentries and that toolsets--act refuses them. The branch where toolHints is newly advertised by a manifest change (it only logs) is not tested either. - Low severity, maintainability,
src/mcpl/toolset-host.ts:549-552. ToolsetHub.forget is never called, so controllers outlive their servers. forget(serverId) is documented as 'A server's configuration went (removed): forget its controller', but nothing calls it. runDisconnectMcplServer deletes mcplServerConfigs and prefixes but leaves the controller. A later server under the same id inherits the old controller's toldEnded and shown sets, and hub.get(id) returns it even when the new config has no toolHints. Either call forget on disconnect or remove the method. - Low severity, maintainability,
src/framework.ts:27565. Notice metadatainviteis written but nothing reads it. recordToolsetNotice addsinvite: truewhen a suggestion is to open, and casts the object to MessageMetadata to get it past the type. A search of src finds no reader of.invite. Since the README says these notices wake nothing, the field is dead data that suggests behaviour that does not exist. Remove it or wire it to something. - Low severity, documentation,
test/mcpl-tool-hints.test.ts:1-5. Test header claims vector 36 is covered in this file. The header lists vector 36 among those this file covers, but no test here exercises it; vector 36 is in mcpl-toolsets-e2e.test.ts ('every tool is static there … (36)'). The header also says vectors 9 and 26 are in the e2e file, which is correct, so 36 is the outlier. - Low severity, documentation,
test/mcpl-toolsets-e2e.test.ts:10. E2E header claims vector 38 is covered, but this file has no such test. The header lists 'toolsets/list with nothing visible clears what the host believed (38)'. None of theitblocks in this file checks that; the empty-statement case is in mcpl-tool-hints.test.ts ('vectors 23, 38'). The header should match what this file tests.
| if (typeof s.pattern === 'string') { | ||
| let re: RegExp | null = null; | ||
| try { re = new RegExp(s.pattern, 'u'); } catch { /* a pattern this runtime can't compile isn't checked */ } | ||
| if (re && !re.test(value)) return `${at}: does not match ${s.pattern}`; | ||
| } |
There was a problem hiding this comment.
Medium severity. Host-side argument check runs a server-supplied regex on the event loop with no bound.
checkArgs compiles any pattern from the tool's inputSchema with new RegExp(s.pattern, 'u') and runs re.test(value) synchronously in the host process. The schema comes from the server (tools/list or a hint definition override), and the value comes from the model. A pattern with catastrophic backtracking, such as ^(a+)+$, plus a long non-matching argument blocks the framework's event loop for every agent and server. Before this change the host never evaluated a server's schema; the server did its own checking. Either skip pattern (the comment already allows unchecked keywords), or cap the input length and pattern complexity.
| const r = await c.call(tool, callArgs, instance); | ||
| const change = p.render(r.change); | ||
| const blocks = [...asBlocks(r.content), ...(change ? [{ type: 'text', text: change }] : [])]; | ||
| if (!blocks.length) blocks.push({ type: 'text', text: r.isError ? `${tool} failed without saying why` : `${tool} done.` }); | ||
| return blocksResult(blocks, r.isError); |
There was a problem hiding this comment.
Medium severity. toolsets--act skips the MCPL dispatch path's state exchange and RFC-005 result handling.
act sends the call through ToolsetController.call → connection.sendToolsCall(tool, args, undefined, meta) and turns the raw content blocks into a ToolResult itself (asBlocks/blocksResult with dataIsContent). The ordinary MCPL dispatch (dispatchMcplToolCall, framework.ts ~30140-30200) does more. It sends stateful feature-set state/checkpoint (checkpointManager) and reads the returned state. It registers and treats RFC-005 references (autoFetchReferences/mcpToolResult) and normalizes the result. None of that happens for a dynamic tool called through act. So a dynamic tool in a stateful feature set never gets its checkpoint, and its returned state is dropped. An MCPL peer's reference blocks reach the model untreated. The description only mentions that lifecycle observers see act calls as module calls; the other gaps are not mentioned.
| if (awaited) { | ||
| this.readChanges.set(res, cs); | ||
| } else { | ||
| // RFC §6.1: a result nobody waits for any more (it timed out) applies | ||
| // all the same, and its change is told on its own. | ||
| this.log(`late ${method} result read: its hint changes applied`); | ||
| this.deliver(cs); | ||
| } |
There was a problem hiding this comment.
Medium severity. Hint changes on results of the framework's own tools/call requests are applied but never told.
For an awaited result, readResult only stores the ChangeSet in readChanges, keyed by the result object, and expects whoever handles that result to call changeText. Only executeMcplToolCall and dispatchMcplToolCall do that, through withToolsetChange. The framework sends other tools/call requests to the same server: server.sendToolsCall('add_reaction'…) at framework.ts:10035, sendToolsCall('send_message'…) at 10221, and sendToolsCallWithDeadline at 27292. Their results are tapped as awaited, so their hints change presence but are never rendered, and no notice is sent either. Tools can appear or disappear with no announcement to the model. Those callers should get a notice fallback, or the awaited flag should mean 'someone will render it'.
| if (p.isDeclared(toolset!)) { | ||
| if (p.declared[toolset!]!.instances && !instance) { | ||
| const open = p.openRecords(toolset); | ||
| if (open.length !== 1) fail(open.length ? `several ${toolset} instances are open (${open.map((o) => o.instance).join(', ')}): say which` : `${toolset} is not open`); | ||
| instance = open[0]!.instance; | ||
| } | ||
| let closed: Awaited<ReturnType<ToolsetController['close']>>; |
There was a problem hiding this comment.
Medium severity. toolsets--close sends an instance for a toolset that has no instances.
open() refuses an instance for a declared toolset without instances (has no instances: open it without one), but close() does not check this. With toolsets--close {toolset:'build', instance:'x'} on a toolset without instances, the instance is passed to c.close(toolset, instance), sent to the server in toolsets/close, and used to build keyOf(toolset!, instance) for the fallback 'closed' label, giving a wrong [srv/build:x] marker. noteClosed ignores the instance, so the host's state and the label disagree. The same validation open() does should apply here.
| it('after a reconnect, an instance the server kept is adopted, not opened again (vector 9)', async () => { | ||
| const opensBefore = calls.length; | ||
| 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 new Promise((r) => setTimeout(r, 400)); | ||
| const look = resultText(await callTool(fw.framework, 'toolsets--look', { server: 'arcade' })); | ||
| assert.match(look, /\[arcade\/game:g1\]/, 'g1 adopted'); | ||
| assert.equal(calls.length, opensBefore); | ||
| const act = await callTool(fw.framework, 'toolsets--act', { tool: 'resign', instance: 'g1', args: {} }); | ||
| assert.ok(!act.isError, resultText(act)); | ||
| }); |
There was a problem hiding this comment.
Medium severity, test coverage. Vector 9 test cannot detect a re-open after reconnect.
The test asserts calls.length === opensBefore to show that nothing was opened again. But calls is the log array of arcadeSpec, which only records tool calls inside an instance (${instance}:${tool}:…). The toolset's open() callback never writes to it. If the host sent toolsets/open again after reconnecting, this assertion would still pass. The test should count toolsets/open requests the server received, or log inside open().
The check toolsets--act makes against a dynamic tool's current definition read the server's schema with no bound: it compiled and ran the schema's `pattern` on the event loop (a backtracking pattern could stall every agent and server), and recursed and fanned out (anyOf, enum) without limit. `pattern` is no longer evaluated; the server checks every call itself. A schema nested deeper than 24 levels, or whose check would visit more than 5,000 nodes and enum entries, isn't checked by the host at all. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…old; close and vector 9 - toolsets--act no longer calls the server itself: the framework resolves it (resolveAct) and dispatches the call of the server's tool down the same path as a direct call (dispatchMcplToolCall, or executeMcplToolCall for a host's or module's call): the agent's allowedTools, the server's tool policy, the state exchange, RFC-005's result reading and RFC-007's lifecycle, with only `_meta["mcpl/instance"]` added. The lifecycle's new retarget() describes the call as that tool before it opens, so observers see it and its class; refreshMcplTools keeps dynamic tools' server, class and feature set (they still never enter the tool list). - A result's tool change is told by whoever shows that result to the agent; one nobody takes by the next macrotask (the framework's own calls: a reaction, a send, an accounted side effect; a host's or module's call) is told as a notice, as a standalone tools/hints is. - toolsets--close refuses an instance for a toolset without instances, as toolsets--open does, and sends nothing. - The vector 9 test counts the toolsets/open requests the server receives across the reconnect. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ield Unit tests build the framework with Object.create, so field initializers don't run; the act marks are created on first use and read optionally, as the framework's other lazily made state is. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Three conflicts, each resolved as the union of both sides: - HOST_METADATA_KEYS: main adds STORED_INJECTION_KEY and PRIMARY_IF_UNREAD_KEY beside this branch's 'callerTurnId'; the list keeps all three. - addModuleMessage: main marks a write for an ephemeral run or a conversation fork (PRIMARY_IF_UNREAD_KEY, forAgent, markedForRun); this branch drops a module's own callerTurnId key. The write now drops the key first, then adds main's mark to what's left. - test/ingress-host-keys.test.ts: the fixture of keys only the host writes holds main's two new keys and this branch's callerTurnId. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A dynamic tool called through `toolsets--act` remains subject to the agent's `allowedTools` in model, `code_execution` script and ephemeral-agent calls. A local scripted MCPL peer and mock inference check a permitted control, a caller-visible refusal and that the peer executes only the permitted control. The three cases fail at #799's earlier head and pass at merged main. This commit adds tests only and covers one visible standalone dynamic tool. Co-Authored-By: GPT-6 Sol <noreply@openai.com>
The host's per-server `enabledTools` and `disabledTools` settings control whether a dynamic tool is announced, appears in `toolsets--look` and can be called through `toolsets--act`. The tests compare permitted and denied settings, with a permitted dynamic control and model and programmatic calls. These policy cases pass at both #799's earlier head and merged main. They pin an existing guarantee. This commit adds tests only. Co-Authored-By: GPT-6 Sol <noreply@openai.com>
…an agent
The seam that "tool hints follow the server's statements, and a change
counts as told only when an agent reads it" put in place:
takeChange(carrier) takes a carrier's change without marking it, and
must be called as the carrier is read, before the next macrotask;
told(cs) counts a change as shown; tell(cs) writes it as a notice, or
drops it when none can be written. RFC-014 §6.1 and
§6.2: a call reports its own consequences, and every change reaches
the agent once.
Who takes a change (changeTaker):
- The model's own call of a dynamic tool takes it as the server's
result is read, and adds it to the tool result after the result has
been read for the model. withToolsetChange had appended the change
to the server's content first, and the model's reading shows
structuredContent only when content is empty, so a structured-only
answer that carried hints reached the model as the change text
alone. The change is counted told once the result is handed back.
An open, close or look the model calls counts its change told as the
module takes it.
- The operator's puppet stores its call and result in the agent's
window as a model's pair, so it takes the change too, keyed by call
id (which the call toolsets--act resolves to keeps), and counts it
told once the pair is stored. It stores a structured-only answer
that isn't an error as that content's JSON, as the model's reading
shows it. An open's or look's rendering counts the definitions it
describes as shown as it renders them, so a notice after a failed
store names them without describing them.
- Anything else (a script's call, a module's or a host's call in
process) may never reach an agent, so its change is told as a notice.
This holds for open, close and look as well as a dynamic tool's
result: the controller's open, close and look re-read take a shown
flag, and an unshown call leaves the change for its notice. Its
result also shows the change to its caller, every tool described
and nothing counted shown (render's describeAll, catalog's mark),
unless a message read after it in the same chunk had the change told
as a notice first.
Ways out, each telling the change as a notice instead:
- a dynamic tool's change that wouldn't fit whole within its result's
inline cap, as the store renders the result when the call returns
(the store's own storedToolResultText, at the call's cap, with the
change compared as the store makes it well-formed), so the result is
left whole rather than cut with the change in its tail;
- a model's call that fails after its result was read;
- a puppet whose pair can't be stored, or whose result is replaced by
the stored error.
Also:
- A notice dropped because no agent is registered is logged, as the
drop for want of pushEvents already was; both lines say look shows
the state the change left.
- look says when it couldn't re-read a server ("X couldn't be asked
just now: this is what the host last knew of it."), in its answer
and in a refusal. The look hint an unconfirmed open, close or act
gives says look asks the server what's open, or says it couldn't.
- ToolsetsModule's constructor takes the taker; left out, every call's
result is taken to reach the agent, as 0.27.0's constructor did.
- withToolsetChange, which nothing calls now, is gone; withChangeText
adds a change to a ToolResult in the same places the reading would
have put a text block.
- The toolset test helper's makeFramework takes config options.
- The README says which calls' changes go as notices, and that look
says when it couldn't ask.
Tests, each from a requirement. All fail at 8aaee1c except two: the
puppet's unstored pair, since #799's puppet took no change, and the
lone surrogate, since #799 appended every change:
- a model's call answered with structured content alone shows it, then
the change, and nothing is told again (8aaee1c: change text only);
- a script's call leaves the change to a notice and its output doesn't
hold it (8aaee1c: the change in the script's output, no notice);
- the puppet's stored result holds the change, and nothing is told
again; it stores a structured-only answer as its JSON before the
change; its toolsets--act stores the dynamic tool's result with the
change;
- a puppet whose pair can't be stored tells the change as a notice
(fails with the notice dropped, or with the change told at the take);
- a model's call whose result plus change would pass the inline cap
keeps its result whole and tells the change (fails with no fit check);
- a model's call whose change fits goes with its result where a
definition holds a lone surrogate (fails when the change is compared
as it came);
- a model's call whose result the host can't read (structured content
nested past what JSON.stringify can take) tells the change (fails
with the failed dispatch's tell removed);
- an open's change goes with the puppet's stored result, read from the
stored pair, and as a notice for a host's own open (fails if a
private call counts as shown, or if the puppet stores a result
without the change); a host's own close is told as a notice too;
- look says when it couldn't ask a server, in its answer and in a
filtered look's refusal (fails with the refusal's line removed);
- a change with no agent to tell is logged;
- a standalone change is a notice only where pushEvents is granted, a
notice starts no turn, and without pushEvents look shows the change.
The existing tests that read the change in a host's own look or
act's result now read it through the puppet, or check only the
change they name. The tests that build a ToolsetsModule themselves
use 0.27.0's form, ToolsetsModule(hub), which doesn't compile without
the taker's default (TS2554). A look through ToolsetsModule(hub) takes
its change as told, so no notice follows, and with a null taker it's
left to a notice; that fails when the default leaves the change to a
notice.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#799's tests didn't reach these. Each test is written from what an agent or an observer meets: - A model's call of a dynamic tool by its listed name is refused at the dispatch, naming the act form, and a model's act that can't resolve is refused; the server receives no tools/call for either. - act's results arrive as a direct call's do: a script's act gets the server's structuredContent, and a server's error reaches the model as an error result. - A dynamic tool keeps its own feature set: with two stateful feature sets, calls through act and direct calls in sequence are each sent their own set's state (the existing state-exchange test has one set, so it passed through the single-set fallback). - A classed dynamic tool called through act is observed with its class and, under an inputs grant by class, its arguments (the existing lifecycle test's dynamic tool was unclassed and its observer got metadata only). All four pass at 8aaee1c, since they pin behaviour #799 already had. Each fails under a mutation of what it pins (Linux, at this branch's head): dropping the dispatch's dynamic refusal, dispatching act other than down the direct-call path, keeping a feature set only for offered tools, and keeping a class only for offered tools. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…three end-to-end gaps Each written from what a server or an agent meets: - toolHints off: the capabilities the server is offered are compared with 0.26.2's literal object, not with the framework's own field; every tool is compared whole (name, description, input schema, in the server's order), not by name; and no toolsets/* request of any kind is sent. The literal is what an unchanged host offered in a run against #799's merge base (3b9c7dd). - vector 9: the drop is seen through the server's own count of connections rather than a swallowed wait; after the reconnect's toolsets/list, a static call's answer (the server answers in order) stands in for the 200 ms sleep; and the instance is used through act before any look, since look re-reads toolsets/list itself. The test pins that an instance the server kept is neither opened again nor lost across the reconnect. - an open's result holds the server's own content (the board) beside the announcement. - an instance the server names is used as given (act with that name plays it), and opening what is open is adopted ("already open"), one open sent (RFC-014 §5.2's two MUSTs). All pass at 8aaee1c: they pin what #799 already did. Mutations (Linux, at this branch's head): with no toolsets/list after a (re)connect, vector 9 fails at its wait; with the statement not applied, vector 9 passes, since the host keeps the instances it believed open across a reconnect, and a later test that reads an ended record fails instead. The server-named instance test still passes with both places that read an open's instance name mutated, since the server's hints carry the name too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…or withdraws toolHints RFC-014 §5.1's requirements had no test for withdrawing toolHints by a manifest change, and vector 33's "closes its instances" removed only a toolset declared without instances. - A toolset with two open instances removed by a manifest change: each instance is told closed, and look shows none. - toolHints withdrawn by a manifest change: the agent is told the server no longer presents toolsets and its tools are called directly now, and every tool is listed. Both pin what #799 already did. The first passes at 8aaee1c; the second fails there at the withdrawal note's wording, which an earlier commit here changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
What
The host side of MCPL RFC-014 (tool presence, toolsets and surfaces), revision 6, opt-in per server with
toolHints: true(default false).RFC-015 (delegated access) is deliberately not included: no server here needs to act as the agent yet, so it's parked. Nothing in this PR touches
access/token, theaccessadvertisement or theaccess.tokengrant path.Off by default, unchanged when off
Without
toolHints: truenothing changes: the server is offered the veryMcplHostCapabilitiesobject every server gets, every tool is listed as before, notoolsets/*request is sent,tools/hintsis still an unknown method (-32601), a push'shintsare not read, and calls carry no_meta.test/mcpl-tool-hints.test.ts("toolHints off") checks each of these.With
toolHints: trueexperimental.mcpl.toolHints: trueto that server only; its hints count only if it advertisedtoolHintstoo.mcpl/presence: "dynamic") are filtered out inrefreshMcplTools, so visibility changes never touch the list (the prompt cache). A dynamic tool called by its prefixed name is refused, pointing attoolsets--act.toolsetsmodule, registered by the firsttoolHintsserver, with four constant tools:toolsets--open {server?, toolset?, instance?, options?},toolsets--act {server?, tool, instance?, args?},toolsets--look {server?, toolset?, instance?, tool?},toolsets--close {server?, toolset?, instance?}.servercan be left out when the name is unambiguous.actis resolved by the module (resolveAct) and dispatched by the framework down the same path as a direct call of the server's tool:dispatchMcplToolCallfor the model's and scripts' calls,executeMcplToolCallfor a host's or module's. That covers allowedTools, tool policy, the state exchange, RFC-005 result reading and RFC-007 lifecycle, with only_meta["mcpl/instance"]added. The newToolLifecycleEmitter.retargetdescribes the call as that tool before it opens, so observers see it and its class. Before sending,actrefuses calls into an undeclared group the host has closed (§5.4). It also checksargsagainst the instance's current definition: its own from hints, else the toolset's, else the listed one. That check is bounded: it never runs a server'spattern, and it leaves a schema deeper than 24 levels or bigger than 5,000 steps to the server, which checks every call itself.src/mcpl/tool-presence.ts,src/mcpl/toolset-host.ts): changes apply in rev order as each message is read, whatever becomes of it.McplServerConnectiongains a read tap (setReadTap) that sees every hinted result (including one that arrives after its timeout: the request is remembered past its deadline), everypush/eventbefore the plane gate or grant decides on it, and everytools/hints. Before this, a late response was dropped unseen, so the tap was needed for vector 28. Revs are consecutive; a skip or a step back schedules recovery:tools/list, thentoolsets/list.toolsets/listis a full statement (per-instance visibility and definitions; a dynamic tool not stated visible is invisible), read after every (re)connect once the policy has settled. What the server reports open is adopted, and nothing is re-opened. From a hintdefinitiononlydescription,inputSchemaandannotationsare taken.endedids are told once, across reconnects and restarts. A duplicateeventId's hints are ignored, and so are a push's hints when it also carriescoalesce(§7); that case triggers a recovery. A manifest change that removes a toolset closes its instances (receipt impactsurface-changed); withdrawingtoolHintsmakes every tool static.tools/hints, a late result, an adoption or a recovery is a notice (kind: 'mcpl-toolsets',system: true, wakes nothing, gated onpushEventslike any delivery). Suggestions are shown, never acted on.Agent.onCompiledToSend, a new optional callback called with the messages of every compile built to send, never a dry run. The framework sets it per agent. After each such compile the hub reads which open toolsets' markers ([server/toolset:instance]) the request carries. A marker the agent has had in view that a later request lacks (compressed, or out of the window) makes a reminder due. Thetoolsetsmodule'sgatherContextthen returns it as anafterUserinjection (namespacemcpl-toolsets), so feat(context): store each afterUser context injection where compile() placed it, so it stays; a module's queryMessages and addMessage take forAgent #782's stored-injection path stores it in the window. Like stored injections themselves, this costs one request: the compile that dropped the announcement is already being sent, and the reminder lands at the next start.Not done
Projection (§8.4) and surface mode are out of scope: a
surfaceserver's static tools are listed like any server's. Streamable-HTTP early-rev holding (§6.1, vector 37) isn't needed: the legacy engine speaks stdio and WebSocket only, which deliver in order. Static↔dynamic moves (§4.2) apply at the next tool-list refresh, which is a list boundary, with a notice; vectors 15/24 aren't in this PR's test list.Tests
test/mcpl-tool-hints.test.ts: a scripted WebSocket server. Covers "toolHints off = unchanged" and vectors 3, 4, 10, 11, 12, 13, 21, 22, 23, 27, 28, 30, 33, 34 and 38, plus suggestions and reminders (the reminder is checked on the compiled request itself).test/mcpl-toolsets-e2e.test.ts: the referenceToolsetServerfrom@animalabs/mcpl-core@0.4.0, a new exact devDependency, served in-process. Covers open, act, look and close, instance routing, the argument check, a result-carried hide and close, adoption after a reconnect (vector 9), and toolHints off with the server's compat tools (vectors 26 and 36).actand a direct call taking one dispatch path (state exchange, lifecycle observers,_meta, the change shown once), a change in a result the agent doesn't see told as a notice, close refusing an instance, and the bounded argument check. The vector 9 test now counts thetoolsets/openrequests the server received.origin/main(3b9c7dd), 7544 tests with 2 failures:code-execution-isolate-runner("a crash's detail has the stderr that comes after its process exits") andhost-quiesce("a REAL process exit after a 2,101-message resume batch…"). On this branch (babcb4b), 7574 tests with 2 failures. One is the samehost-quiescetest. The other is anexplicit-prose-routingsubtest that timed out under load ("Timed out waiting for the next turn to settle"); it passes 3/3 on its own and touches no toolsets code. No new failures.actplays, the instance is picked automatically when only one is open, and the bot replies. A move that doesn't fit the definition's pattern is refused before it's sent.lookandclosereport as expected.🤖 Generated with Claude Code