Skip to content

Commit 4c7f7b4

Browse files
committed
fix(app): align registerTool runtime with declared types
- Handler reads registeredTool.{input,output}Schema so update() takes effect - Call cb(extra) when no inputSchema, matching AppToolCallback<undefined> - Throw on duplicate name; guard list_changed pre-connect; notify on register - Drop misleading zod optional peer (still required by generated/schema.ts) - examples: fix pdf search/find stale match count; threejs set-scene-source re-render - spec: Zod → Standard Schema; clarify tools availability via tools/list after init
1 parent 68d719f commit 4c7f7b4

6 files changed

Lines changed: 86 additions & 30 deletions

File tree

examples/pdf-server/src/mcp-app.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5136,7 +5136,7 @@ const PageIntervalSchema = z.object({
51365136
/** Dispatch a command via processCommands and return a text result. */
51375137
async function runCommand(
51385138
cmd: PdfCommand,
5139-
okText: string,
5139+
okText: string | (() => string),
51405140
): Promise<CallToolResult> {
51415141
if (!pdfDocument) {
51425142
return {
@@ -5145,7 +5145,8 @@ async function runCommand(
51455145
};
51465146
}
51475147
await processCommands([cmd]);
5148-
return { content: [{ type: "text" as const, text: okText }] };
5148+
const text = typeof okText === "function" ? okText() : okText;
5149+
return { content: [{ type: "text" as const, text }] };
51495150
}
51505151

51515152
app.registerTool(
@@ -5218,7 +5219,7 @@ app.registerTool(
52185219
async ({ query }) =>
52195220
runCommand(
52205221
{ type: "search", query },
5221-
`Searched for "${query}": ${allMatches.length} match(es)`,
5222+
() => `Searched for "${query}": ${allMatches.length} match(es)`,
52225223
),
52235224
);
52245225

@@ -5235,7 +5236,7 @@ app.registerTool(
52355236
async ({ query }) =>
52365237
runCommand(
52375238
{ type: "find", query },
5238-
`Found ${allMatches.length} match(es) for "${query}"`,
5239+
() => `Found ${allMatches.length} match(es) for "${query}"`,
52395240
),
52405241
);
52415242

examples/threejs-server/src/mcp-app-wrapper.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ export interface ViewProps<TToolInput = Record<string, unknown>> {
7171
function registerWidgetTools(
7272
app: App,
7373
sceneStateRef: React.RefObject<SceneState>,
74+
setToolInputs: (args: Record<string, unknown>) => void,
7475
): void {
7576
// Tool: set-scene-source - Update the scene source/configuration
7677
app.registerTool(
@@ -95,12 +96,12 @@ function registerWidgetTools(
9596
}),
9697
},
9798
async (args) => {
98-
// Update scene state
9999
sceneStateRef.current.code = args.code;
100100
if (args.height !== undefined) {
101101
sceneStateRef.current.height = args.height;
102102
}
103103
sceneStateRef.current.error = null;
104+
setToolInputs({ code: args.code, height: sceneStateRef.current.height });
104105

105106
const result = {
106107
success: true,
@@ -179,7 +180,7 @@ function McpAppWrapper() {
179180
appRef.current = app;
180181

181182
// Register widget interaction tools before connect()
182-
registerWidgetTools(app, sceneStateRef);
183+
registerWidgetTools(app, sceneStateRef, setToolInputs);
183184

184185
// Complete tool input (streaming finished)
185186
app.ontoolinput = (params) => {

package.json

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -118,9 +118,6 @@
118118
},
119119
"react-dom": {
120120
"optional": true
121-
},
122-
"zod": {
123-
"optional": true
124121
}
125122
},
126123
"overrides": {

specification/draft/apps.mdx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1775,8 +1775,8 @@ await app.connect(new PostMessageTransport(window.parent));
17751775
|-------|------|----------|-------------|
17761776
| `name` | string | Yes | Unique tool identifier |
17771777
| `description` | string | No | Human-readable description for agent |
1778-
| `inputSchema` | Zod schema or JSON Schema | No | Validates arguments |
1779-
| `outputSchema` | Zod schema | No | Validates return value |
1778+
| `inputSchema` | [Standard Schema](https://standardschema.dev/) | No | Validates arguments; serialized to JSON Schema for `tools/list` |
1779+
| `outputSchema` | [Standard Schema](https://standardschema.dev/) | No | Validates `structuredContent` |
17801780
| `annotations` | ToolAnnotations | No | MCP tool hints (e.g., `readOnlyHint`) |
17811781
| `_meta` | object | No | Custom metadata |
17821782

@@ -2292,7 +2292,7 @@ This specification defines the Minimum Viable Product (MVP) for MCP Apps.
22922292
- **App-Provided Tools:** Apps can register tools via `app.registerTool()` that agents can call
22932293
- Bidirectional tool flow (Apps consume server tools AND provide app tools)
22942294
- Full lifecycle management (enable/disable/update/remove)
2295-
- Schema validation with Zod
2295+
- Schema validation via [Standard Schema](https://standardschema.dev/) (Zod, ArkType, Valibot, …)
22962296
- Tool list change notifications
22972297
22982298
**Content Types (deferred from MVP):**
@@ -2602,7 +2602,7 @@ Hosts MAY implement different permission levels based on tool annotations:
26022602
26032603
App tools MUST be tied to the app's lifecycle:
26042604
2605-
- Tools become available only after app sends `notifications/tools/list_changed`
2605+
- Tools become available once the app advertises the `tools` capability in `ui/initialize` and the host issues `tools/list`; subsequent changes are signaled via `notifications/tools/list_changed`
26062606
- Tools automatically disappear when app iframe is torn down
26072607
- Hosts MUST NOT persist app tool registrations across sessions
26082608
- Calling a tool from a closed app MUST return an error

src/app-bridge.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -772,6 +772,53 @@ describe("App <-> AppBridge integration", () => {
772772
// Tool should no longer be registered (internal check)
773773
});
774774

775+
it("registerTool throws on duplicate name", () => {
776+
app.registerTool("dup", {}, async () => ({ content: [] }));
777+
expect(() =>
778+
app.registerTool("dup", {}, async () => ({ content: [] })),
779+
).toThrow(/already registered/);
780+
});
781+
782+
it("enable/disable/update/remove pre-connect do not throw", () => {
783+
const tool = app.registerTool("t", {}, async () => ({ content: [] }));
784+
expect(() => tool.disable()).not.toThrow();
785+
expect(() => tool.enable()).not.toThrow();
786+
expect(() => tool.update({ description: "x" })).not.toThrow();
787+
expect(() => tool.remove()).not.toThrow();
788+
});
789+
790+
it("callback without inputSchema receives extra as first arg", async () => {
791+
await app.connect(appTransport);
792+
let receivedExtra: any;
793+
app.registerTool("noargs", {}, async (extra: any) => {
794+
receivedExtra = extra;
795+
return { content: [] };
796+
});
797+
await bridge.callTool({ name: "noargs", arguments: {} });
798+
expect(receivedExtra).toBeDefined();
799+
expect(receivedExtra.signal).toBeInstanceOf(AbortSignal);
800+
});
801+
802+
it("update({inputSchema}) is honored by handler validation", async () => {
803+
await app.connect(appTransport);
804+
const tool = app.registerTool(
805+
"evolving",
806+
{ inputSchema: z.object({ a: z.string() }) as any },
807+
async (args: any) => ({
808+
content: [{ type: "text" as const, text: JSON.stringify(args) }],
809+
}),
810+
);
811+
expect(
812+
bridge.callTool({ name: "evolving", arguments: { a: 123 } }),
813+
).rejects.toThrow(/Invalid input/);
814+
tool.update({ inputSchema: z.object({ a: z.number() }) as any });
815+
const result = await bridge.callTool({
816+
name: "evolving",
817+
arguments: { a: 123 },
818+
});
819+
expect(result.content[0]).toEqual({ type: "text", text: '{"a":123}' });
820+
});
821+
775822
it("tool throws error when disabled and called", async () => {
776823
await app.connect(appTransport);
777824

src/app.ts

Lines changed: 27 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -377,7 +377,13 @@ export class App extends ProtocolWithEvents<
377377
},
378378
cb: AppToolCallback<InputArgs>,
379379
): RegisteredAppTool {
380+
if (this._registeredTools[name]) {
381+
throw new Error(`Tool ${name} is already registered`);
382+
}
380383
const app = this;
384+
const notify = () => {
385+
if (app.transport) void app.sendToolListChanged();
386+
};
381387
const registeredTool: RegisteredAppTool = {
382388
title: config.title,
383389
description: config.description,
@@ -388,38 +394,41 @@ export class App extends ProtocolWithEvents<
388394
enabled: true,
389395
enable(): void {
390396
this.enabled = true;
391-
app.sendToolListChanged();
397+
notify();
392398
},
393399
disable(): void {
394400
this.enabled = false;
395-
app.sendToolListChanged();
401+
notify();
396402
},
397403
update(updates) {
398404
Object.assign(this, updates);
399-
app.sendToolListChanged();
405+
notify();
400406
},
401407
remove() {
402408
delete app._registeredTools[name];
403-
app.sendToolListChanged();
409+
notify();
404410
},
405411
handler: async (rawArgs, extra) => {
406412
if (!registeredTool.enabled) {
407413
throw new Error(`Tool ${name} is disabled`);
408414
}
409-
const parsedArgs = config.inputSchema
410-
? await validateStandardSchema(
411-
config.inputSchema,
412-
rawArgs,
413-
`Invalid input for tool ${name}: `,
414-
)
415-
: rawArgs;
416-
const result = await (cb as AppToolCallback<StandardSchemaV1>)(
417-
parsedArgs,
418-
extra,
419-
);
420-
if (config.outputSchema) {
415+
let result: CallToolResult;
416+
if (registeredTool.inputSchema) {
417+
const parsedArgs = await validateStandardSchema(
418+
registeredTool.inputSchema,
419+
rawArgs,
420+
`Invalid input for tool ${name}: `,
421+
);
422+
result = await (cb as AppToolCallback<StandardSchemaV1>)(
423+
parsedArgs,
424+
extra,
425+
);
426+
} else {
427+
result = await (cb as AppToolCallback<undefined>)(extra);
428+
}
429+
if (registeredTool.outputSchema) {
421430
result.structuredContent = (await validateStandardSchema(
422-
config.outputSchema,
431+
registeredTool.outputSchema,
423432
result.structuredContent,
424433
`Invalid output for tool ${name}: `,
425434
)) as CallToolResult["structuredContent"];
@@ -440,6 +449,7 @@ export class App extends ProtocolWithEvents<
440449
}
441450

442451
this.ensureToolHandlersInitialized();
452+
notify();
443453
return registeredTool;
444454
}
445455

0 commit comments

Comments
 (0)