Skip to content

Commit 68fc188

Browse files
committed
feat(app): type structuredContent from outputSchema in registerTool
AppToolCallback gains an Out param: when outputSchema is provided, structuredContent is required and typed via StandardSchemaV1.InferOutput, with an `isError: true` escape hatch. Without outputSchema, return type is unchanged (CallToolResult). Uses intersection rather than Omit since CallToolResult's index signature swallows Omit's known keys. Also drops the `as any` casts on z.object(...) in tests — zod 4 satisfies StandardSchemaV1 directly.
1 parent 4c7f7b4 commit 68fc188

2 files changed

Lines changed: 38 additions & 19 deletions

File tree

src/app-bridge.test.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -698,8 +698,8 @@ describe("App <-> AppBridge integration", () => {
698698
});
699699

700700
it("registerTool creates a registered tool", async () => {
701-
const InputSchema = z.object({ name: z.string() }) as any;
702-
const OutputSchema = z.object({ greeting: z.string() }) as any;
701+
const InputSchema = z.object({ name: z.string() });
702+
const OutputSchema = z.object({ greeting: z.string() });
703703

704704
const tool = app.registerTool(
705705
"greet",
@@ -803,15 +803,15 @@ describe("App <-> AppBridge integration", () => {
803803
await app.connect(appTransport);
804804
const tool = app.registerTool(
805805
"evolving",
806-
{ inputSchema: z.object({ a: z.string() }) as any },
806+
{ inputSchema: z.object({ a: z.string() }) },
807807
async (args: any) => ({
808808
content: [{ type: "text" as const, text: JSON.stringify(args) }],
809809
}),
810810
);
811811
expect(
812812
bridge.callTool({ name: "evolving", arguments: { a: 123 } }),
813813
).rejects.toThrow(/Invalid input/);
814-
tool.update({ inputSchema: z.object({ a: z.number() }) as any });
814+
tool.update({ inputSchema: z.object({ a: z.number() }) });
815815
const result = await bridge.callTool({
816816
name: "evolving",
817817
arguments: { a: 123 },
@@ -845,7 +845,7 @@ describe("App <-> AppBridge integration", () => {
845845
});
846846

847847
it("tool validates input schema", async () => {
848-
const InputSchema = z.object({ name: z.string() }) as any;
848+
const InputSchema = z.object({ name: z.string() });
849849

850850
const tool = app.registerTool(
851851
"greet",
@@ -877,7 +877,7 @@ describe("App <-> AppBridge integration", () => {
877877
});
878878

879879
it("tool validates output schema", async () => {
880-
const OutputSchema = z.object({ greeting: z.string() }) as any;
880+
const OutputSchema = z.object({ greeting: z.string() });
881881

882882
const tool = app.registerTool(
883883
"greet",
@@ -1031,7 +1031,7 @@ describe("App <-> AppBridge integration", () => {
10311031
"greet",
10321032
{
10331033
description: "Greets user",
1034-
inputSchema: z.object({ name: z.string() }) as any,
1034+
inputSchema: z.object({ name: z.string() }),
10351035
},
10361036
async (args: any) => ({
10371037
content: [{ type: "text" as const, text: `Hello, ${args.name}!` }],
@@ -1072,7 +1072,7 @@ describe("App <-> AppBridge integration", () => {
10721072
"greet",
10731073
{
10741074
description: "Greets user",
1075-
inputSchema: z.object({ name: z.string() }) as any,
1075+
inputSchema: z.object({ name: z.string() }),
10761076
},
10771077
async (args: any) => ({
10781078
content: [{ type: "text" as const, text: `Hello, ${args.name}!` }],
@@ -1119,7 +1119,7 @@ describe("App <-> AppBridge integration", () => {
11191119
"add",
11201120
{
11211121
description: "Add two numbers",
1122-
inputSchema: z.object({ a: z.number(), b: z.number() }) as any,
1122+
inputSchema: z.object({ a: z.number(), b: z.number() }),
11231123
},
11241124
async (args: any) => ({
11251125
content: [
@@ -1136,7 +1136,7 @@ describe("App <-> AppBridge integration", () => {
11361136
"multiply",
11371137
{
11381138
description: "Multiply two numbers",
1139-
inputSchema: z.object({ a: z.number(), b: z.number() }) as any,
1139+
inputSchema: z.object({ a: z.number(), b: z.number() }),
11401140
},
11411141
async (args: any) => ({
11421142
content: [

src/app.ts

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -175,23 +175,41 @@ type RequestHandlerExtra = Parameters<
175175
Parameters<App["setRequestHandler"]>[1]
176176
>[1];
177177

178+
/**
179+
* Result of an app-registered tool callback. When `Out` is provided,
180+
* `structuredContent` is required and typed (unless `isError: true`).
181+
*/
182+
export type AppToolResult<
183+
Out extends StandardSchemaV1 | undefined = undefined,
184+
> = Out extends StandardSchemaV1
185+
?
186+
| (CallToolResult & {
187+
structuredContent: StandardSchemaV1.InferOutput<Out>;
188+
isError?: false;
189+
})
190+
| (CallToolResult & { isError: true })
191+
: CallToolResult;
192+
178193
/**
179194
* Callback for an app-registered tool. When `In` is provided, `args` is the
180195
* validated/parsed input; when `In` is `undefined`, the callback receives only
181-
* `extra`.
196+
* `extra`. When `Out` is provided, the return's `structuredContent` is typed.
182197
*
183198
* Mirrors `ToolCallback` from `@modelcontextprotocol/sdk/server/mcp.js` but is
184199
* parameterized over {@link StandardSchemaV1} instead of zod, so any
185200
* Standard-Schema-compatible library (Zod, ArkType, Valibot, …) can be used.
186201
*/
187202
export type AppToolCallback<
188203
In extends StandardSchemaV1 | undefined = undefined,
204+
Out extends StandardSchemaV1 | undefined = undefined,
189205
> = In extends StandardSchemaV1
190206
? (
191207
args: StandardSchemaV1.InferOutput<In>,
192208
extra: RequestHandlerExtra,
193-
) => CallToolResult | Promise<CallToolResult>
194-
: (extra: RequestHandlerExtra) => CallToolResult | Promise<CallToolResult>;
209+
) => AppToolResult<Out> | Promise<AppToolResult<Out>>
210+
: (
211+
extra: RequestHandlerExtra,
212+
) => AppToolResult<Out> | Promise<AppToolResult<Out>>;
195213

196214
/**
197215
* Handle returned by {@link App.registerTool}. Mirrors `RegisteredTool` from
@@ -363,7 +381,7 @@ export class App extends ProtocolWithEvents<
363381
}
364382

365383
registerTool<
366-
OutputArgs extends StandardSchemaV1,
384+
OutputArgs extends undefined | StandardSchemaV1 = undefined,
367385
InputArgs extends undefined | StandardSchemaV1 = undefined,
368386
>(
369387
name: string,
@@ -375,7 +393,7 @@ export class App extends ProtocolWithEvents<
375393
annotations?: ToolAnnotations;
376394
_meta?: Record<string, unknown>;
377395
},
378-
cb: AppToolCallback<InputArgs>,
396+
cb: AppToolCallback<InputArgs, OutputArgs>,
379397
): RegisteredAppTool {
380398
if (this._registeredTools[name]) {
381399
throw new Error(`Tool ${name} is already registered`);
@@ -419,12 +437,13 @@ export class App extends ProtocolWithEvents<
419437
rawArgs,
420438
`Invalid input for tool ${name}: `,
421439
);
422-
result = await (cb as AppToolCallback<StandardSchemaV1>)(
423-
parsedArgs,
440+
result = await (
441+
cb as AppToolCallback<StandardSchemaV1, StandardSchemaV1>
442+
)(parsedArgs, extra);
443+
} else {
444+
result = await (cb as AppToolCallback<undefined, StandardSchemaV1>)(
424445
extra,
425446
);
426-
} else {
427-
result = await (cb as AppToolCallback<undefined>)(extra);
428447
}
429448
if (registeredTool.outputSchema) {
430449
result.structuredContent = (await validateStandardSchema(

0 commit comments

Comments
 (0)