Skip to content

Commit 4150c6f

Browse files
authored
feat: add typed MCP code-mode API (#88678)
* feat: add typed MCP code-mode API * fix: stabilize code-mode namespace drain * fix: preserve code-mode run cap * fix: reserve code-mode snapshot capacity
1 parent d1b514a commit 4150c6f

8 files changed

Lines changed: 1149 additions & 119 deletions

File tree

docs/reference/code-mode.md

Lines changed: 71 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -329,8 +329,12 @@ type CodeModeFailedResult = {
329329
};
330330
```
331331

332-
`exec` returns `waiting` when the QuickJS VM suspends with resumable state. The
333-
result includes a `runId` for `wait`.
332+
`exec` returns `waiting` when the QuickJS VM suspends with resumable state that
333+
still needs a model-visible continuation. The result includes a `runId` for
334+
`wait`. Namespace bridge calls, including MCP namespace calls, are auto-drained
335+
inside the same `exec`/`wait` call while they are ready, so a compact code block
336+
can inspect `$api()` and call an MCP tool without forcing one model tool call per
337+
namespace await.
334338

335339
`exec` returns `completed` only when the guest VM has no pending work and the
336340
final value is JSON-compatible after OpenClaw's output adapter runs.
@@ -436,18 +440,55 @@ const hits = await tools.web_search({ query: "OpenClaw code mode" });
436440
```
437441

438442
MCP catalog entries are not callable through `tools.call(...)` or convenience
439-
functions in code mode. Use the generated `MCP` namespace instead:
443+
functions in code mode. They are exposed only through the generated `MCP`
444+
namespace, which includes TypeScript-style API headers for discovery:
440445

441446
```typescript
447+
const servers = await MCP.$api();
448+
const githubApi = await MCP.github.$api();
449+
const createIssueApi = await MCP.github.$api("createIssue", { schema: true });
450+
442451
const issue = await MCP.github.createIssue({
443452
owner: "openclaw",
444453
repo: "openclaw",
445454
title: "Investigate gateway logs",
446455
});
447456

448457
const snapshot = await MCP.chromeDevtools.takeSnapshot({ output: "markdown" });
449-
const resource = await MCP.docs.resources.read("memo://one");
450-
const prompt = await MCP.docs.prompts.get("brief", { topic: "release" });
458+
const resource = await MCP.docs.resources.read({ uri: "memo://one" });
459+
const prompt = await MCP.docs.prompts.get({
460+
name: "brief",
461+
arguments: { topic: "release" },
462+
});
463+
```
464+
465+
`MCP.<server>.$api()` returns a compact header inferred from MCP tool metadata:
466+
467+
```typescript
468+
type McpToolResult = {
469+
content?: unknown[];
470+
structuredContent?: unknown;
471+
isError?: boolean;
472+
[key: string]: unknown;
473+
};
474+
475+
declare namespace MCP.github {
476+
/** Return this TypeScript-style API header. */
477+
function $api(toolName?: string, options?: { schema?: boolean }): Promise<McpApiHeader>;
478+
479+
/**
480+
* Create a GitHub issue.
481+
* @param owner Repository owner
482+
* @param repo Repository name
483+
* @param title Issue title
484+
*/
485+
function createIssue(input: {
486+
owner: string;
487+
repo: string;
488+
title: string;
489+
body?: string;
490+
}): Promise<McpToolResult>;
491+
}
451492
```
452493

453494
The guest runtime must not expose host objects directly. Inputs and outputs cross
@@ -493,8 +534,9 @@ run follows this path:
493534
6. Guest calls suspend through the worker bridge, resolve the namespace path on
494535
the host, map the call to a declared plugin-owned catalog tool, and execute
495536
that tool through `ToolSearchRuntime.call`.
496-
7. `wait` resumes the same namespace runtime when a code-mode run suspended on
497-
nested tool work.
537+
7. OpenClaw auto-drains ready namespace bridge calls inside the active
538+
`exec`/`wait` tool call. If namespace work is still pending at the timeout or
539+
the guest yields explicitly, `wait` resumes the same namespace runtime later.
498540
8. Plugin rollback or uninstall calls `clearCodeModeNamespacesForPlugin(pluginId)`
499541
so stale globals do not survive a failed plugin load.
500542

@@ -701,9 +743,10 @@ This prevents recursion and keeps the model-facing contract narrow.
701743

702744
MCP entries stay in the run-scoped catalog so policy, approvals, hooks,
703745
telemetry, transcript projection, and exact tool ids remain shared with normal
704-
tool execution. The guest-facing `tools.call(...)` bridge rejects MCP entries;
705-
the generated `MCP.<server>.<tool>(...)` namespace resolves back to the exact
706-
catalog id and then dispatches through the same executor path.
746+
tool execution. The guest-facing `ALL_TOOLS`, `tools.search(...)`,
747+
`tools.describe(...)`, and `tools.call(...)` views omit MCP entries. The
748+
generated `MCP.<server>.<tool>({ ...input })` namespace resolves back to the
749+
exact catalog id and then dispatches through the same executor path.
707750

708751
## Tool Search interaction
709752

@@ -716,8 +759,9 @@ When `tools.codeMode.enabled` is true and code mode activates:
716759
or `tool_call` as model-visible tools.
717760
- The same cataloging idea moves inside the guest runtime.
718761
- The guest runtime receives compact `ALL_TOOLS` metadata and search, describe,
719-
and call helpers.
720-
- MCP calls use the generated `MCP` namespace instead of `tools.call(...)`.
762+
and call helpers for non-MCP tools.
763+
- MCP calls use the generated `MCP` namespace and its `$api()` headers instead
764+
of `tools.call(...)`.
721765
- Nested calls dispatch through the same OpenClaw executor path that Tool Search
722766
uses.
723767

@@ -934,11 +978,13 @@ Code mode coverage should prove:
934978
active for the run
935979
- raw no-tool runs, `disableTools`, and empty allowlists do not trigger code-mode
936980
payload enforcement
937-
- all effective tools appear in `ALL_TOOLS`
981+
- all effective non-MCP tools appear in `ALL_TOOLS`
938982
- denied tools do not appear in `ALL_TOOLS`
939983
- `tools.search`, `tools.describe`, and `tools.call` work for OpenClaw tools
940-
- MCP namespace calls work for visible MCP tools and direct MCP `tools.call`
941-
attempts fail closed
984+
- MCP namespace `$api()` returns TypeScript-style headers inferred from MCP
985+
schemas
986+
- MCP namespace calls work for visible MCP tools with one object input, while
987+
direct MCP catalog entries are absent from `tools.*`
942988
- Tool Search control tools are hidden from both the model surface and the hidden
943989
catalog
944990
- nested calls preserve approval and hook behavior
@@ -968,14 +1014,16 @@ Run these as integration or end-to-end tests when changing the runtime:
9681014
7. In `exec`, read `ALL_TOOLS` and assert the effective test tools are present.
9691015
8. In `exec`, call OpenClaw/plugin/client tools through `tools.search`,
9701016
`tools.describe`, and `tools.call`.
971-
9. In `exec`, call MCP tools through `MCP.<server>.<tool>(...)` and assert direct
972-
MCP `tools.call(...)` attempts fail.
973-
10. Assert denied tools are absent and cannot be called by guessed id.
974-
11. Start a nested tool call that resolves after `exec` returns `waiting`.
975-
12. Call `wait` and assert the restored VM receives the tool result.
976-
13. Assert the final answer contains output produced after restore.
977-
14. Assert timeout, abort, and snapshot expiry clean up runtime state.
978-
15. Export trajectory and assert nested calls are visible under the parent
1017+
9. In `exec`, call `MCP.$api()` and `MCP.<server>.$api()` and assert the headers
1018+
describe visible MCP tools.
1019+
10. In `exec`, call MCP tools through `MCP.<server>.<tool>({ ...input })` and
1020+
assert direct MCP catalog entries are absent from `ALL_TOOLS` and `tools.*`.
1021+
11. Assert denied tools are absent and cannot be called by guessed id.
1022+
12. Start a nested tool call that resolves after `exec` returns `waiting`.
1023+
13. Call `wait` and assert the restored VM receives the tool result.
1024+
14. Assert the final answer contains output produced after restore.
1025+
15. Assert timeout, abort, and snapshot expiry clean up runtime state.
1026+
16. Export trajectory and assert nested calls are visible under the parent
9791027
code-mode call.
9801028

9811029
Docs-only changes to this page should still run `pnpm check:docs`.

extensions/qa-lab/src/providers/mock-openai/server.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ const QA_SKILL_WORKSHOP_REVIEW_PROMPT_RE = /Review transcript for durable skill
174174
const QA_RELEASE_AUDIT_PROMPT_RE = /release readiness audit for the small project/i;
175175
const QA_TOOL_SEARCH_PROMPT_RE = /tool search qa check/i;
176176
const QA_TOOL_SEARCH_FAILURE_PROMPT_RE = /tool search qa failure/i;
177+
const QA_MCP_CODE_MODE_PROMPT_RE = /mcp code mode qa check/i;
177178

178179
type MockScenarioState = {
179180
subagentFanoutPhase: number;
@@ -1806,6 +1807,38 @@ async function buildResponsesPayload(
18061807
return buildToolCallEventsWithArgs(targetTool, plannedArgs);
18071808
}
18081809
}
1810+
if (QA_MCP_CODE_MODE_PROMPT_RE.test(allInputText)) {
1811+
if (!toolOutput && hasDeclaredTool(body, "exec")) {
1812+
return buildToolCallEventsWithArgs("exec", {
1813+
language: "javascript",
1814+
code: [
1815+
"const rootApi = await MCP.$api();",
1816+
'const api = await MCP.fixture.$api("lookupNote", { schema: true });',
1817+
'const result = await MCP.fixture.lookupNote({ id: "alpha" });',
1818+
"return {",
1819+
' marker: "MCP_CODE_MODE_TOOL_RESULT",',
1820+
" rootServers: rootApi.servers,",
1821+
" headerHasLookup: api.header.includes('function lookupNote'),",
1822+
" schemaKeys: Object.keys(api.schemas),",
1823+
" resultText: result.content?.[0]?.text,",
1824+
" allHasMcp: ALL_TOOLS.some((tool) => tool.source === 'mcp'),",
1825+
"};",
1826+
].join("\n"),
1827+
});
1828+
}
1829+
if (
1830+
toolJson?.status === "waiting" &&
1831+
typeof toolJson.runId === "string" &&
1832+
hasDeclaredTool(body, "wait")
1833+
) {
1834+
return buildToolCallEventsWithArgs("wait", { runId: toolJson.runId });
1835+
}
1836+
if (/MCP_CODE_MODE_TOOL_RESULT|fixture-note-alpha/.test(toolOutput)) {
1837+
return buildAssistantEvents(
1838+
"MCP_CODE_MODE_OK unclear=none improvement=virtual-header-files-would-avoid-the-first-api-call",
1839+
);
1840+
}
1841+
}
18091842
if (
18101843
allInputText.includes(QA_SUBAGENT_DIRECT_FALLBACK_MARKER) &&
18111844
/Internal task completion event/i.test(allInputText)

0 commit comments

Comments
 (0)