feat(compat): registerTool/registerPrompt accept raw Zod shape, auto-wrap with z.object() (#1901)
Co-authored-by: Konstantin Konstantinov <KKonstantinov@users.noreply.github.com>
This commit is contained in:
parent
9fc9070b7b
commit
e15a8ef3be
7 changed files with 387 additions and 12 deletions
8
.changeset/register-rawshape-compat.md
Normal file
8
.changeset/register-rawshape-compat.md
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
'@modelcontextprotocol/core': patch
|
||||
'@modelcontextprotocol/server': patch
|
||||
---
|
||||
|
||||
`registerTool`/`registerPrompt` accept a raw Zod shape (`{ field: z.string() }`) for `inputSchema`/`outputSchema`/`argsSchema` in addition to a wrapped Standard Schema. Raw shapes are auto-wrapped with `z.object()`. The raw-shape overloads are `@deprecated`; prefer wrapping with `z.object()`.
|
||||
|
||||
Also widens the `completable()` constraint from `StandardSchemaWithJSON` to `StandardSchemaV1` so v1's `completable(z.string(), fn)` continues to work.
|
||||
|
|
@ -15,6 +15,7 @@ export * from './types/index.js';
|
|||
export * from './util/inMemory.js';
|
||||
export * from './util/schema.js';
|
||||
export * from './util/standardSchema.js';
|
||||
export * from './util/zodCompat.js';
|
||||
|
||||
// experimental exports
|
||||
export * from './experimental/index.js';
|
||||
|
|
|
|||
80
packages/core/src/util/zodCompat.ts
Normal file
80
packages/core/src/util/zodCompat.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
/**
|
||||
* Zod-specific helpers for the v1-compat raw-shape shorthand on
|
||||
* `registerTool`/`registerPrompt`. Kept separate from `standardSchema.ts` so
|
||||
* that file stays library-agnostic per the Standard Schema spec.
|
||||
*/
|
||||
|
||||
import * as z from 'zod/v4';
|
||||
|
||||
import type { StandardSchemaWithJSON } from './standardSchema.js';
|
||||
import { isStandardSchema } from './standardSchema.js';
|
||||
|
||||
function isZodV4Schema(v: unknown): v is z.ZodType {
|
||||
// `_zod` is the v4 internal namespace property. Zod v3 schemas have `_def`
|
||||
// and (since 3.24) `~standard.vendor === 'zod'`, but never `_zod`. We require
|
||||
// v4 because the wrap path below uses v4's `z.object()`, which cannot consume
|
||||
// v3 field schemas.
|
||||
return typeof v === 'object' && v !== null && '_zod' in v;
|
||||
}
|
||||
|
||||
function looksLikeZodV3(v: unknown): boolean {
|
||||
// v3 schemas have `_def.typeName` (e.g. 'ZodString') and no `_zod`.
|
||||
return (
|
||||
typeof v === 'object' &&
|
||||
v !== null &&
|
||||
!('_zod' in v) &&
|
||||
'_def' in v &&
|
||||
typeof (v as { _def?: { typeName?: unknown } })._def?.typeName === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects a "raw shape" — a plain object whose values are Zod field schemas,
|
||||
* e.g. `{ name: z.string() }`. Powers the auto-wrap in
|
||||
* {@linkcode normalizeRawShapeSchema}, which wraps with `z.object()`, so only
|
||||
* Zod values are supported.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function isZodRawShape(obj: unknown): obj is Record<string, z.ZodType> {
|
||||
if (typeof obj !== 'object' || obj === null) return false;
|
||||
if (isStandardSchema(obj)) return false;
|
||||
// Require a plain object literal: rejects arrays, Date, Map, RegExp, class instances, etc.
|
||||
// Object.create(null) is also accepted.
|
||||
const proto = Object.getPrototypeOf(obj);
|
||||
if (proto !== Object.prototype && proto !== null) return false;
|
||||
// [].every() is true, so an empty plain object is a valid raw shape (matches v1).
|
||||
return Object.values(obj).every(v => isZodV4Schema(v));
|
||||
}
|
||||
|
||||
/**
|
||||
* Accepts either a {@linkcode StandardSchemaWithJSON} or a raw Zod shape
|
||||
* `{ field: z.string() }` and returns a {@linkcode StandardSchemaWithJSON}.
|
||||
* Raw shapes are wrapped with `z.object()` so the rest of the pipeline sees a
|
||||
* uniform schema type; already-wrapped schemas pass through unchanged.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function normalizeRawShapeSchema(
|
||||
schema: StandardSchemaWithJSON | Record<string, z.ZodType> | undefined
|
||||
): StandardSchemaWithJSON | undefined {
|
||||
if (schema === undefined) return undefined;
|
||||
if (isZodRawShape(schema)) {
|
||||
return z.object(schema) as StandardSchemaWithJSON;
|
||||
}
|
||||
if (typeof schema === 'object' && schema !== null && !isStandardSchema(schema) && Object.values(schema).some(v => looksLikeZodV3(v))) {
|
||||
throw new TypeError(
|
||||
'Raw-shape inputSchema/outputSchema/argsSchema fields must be Zod v4 schemas. Got a Zod v3 field schema. Import from `zod/v4` (or upgrade your zod import), or wrap with `z.object({...})` yourself.'
|
||||
);
|
||||
}
|
||||
if (!isStandardSchema(schema)) {
|
||||
throw new TypeError(
|
||||
'inputSchema/outputSchema/argsSchema must be a Standard Schema (e.g. z.object({...})) or a raw Zod shape ({ field: z.string() }).'
|
||||
);
|
||||
}
|
||||
// Any StandardSchema passes through; standardSchemaToJsonSchema owns the per-vendor
|
||||
// handling for schemas without `~standard.jsonSchema` (zod 4.0-4.1 fallback, zod 3
|
||||
// and non-zod errors). Gating on `~standard.jsonSchema` here would unreachably
|
||||
// front-run that fallback.
|
||||
return schema;
|
||||
}
|
||||
89
packages/core/test/util/zodCompat.test.ts
Normal file
89
packages/core/test/util/zodCompat.test.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { vi } from 'vitest';
|
||||
import * as z from 'zod/v4';
|
||||
|
||||
import { standardSchemaToJsonSchema } from '../../src/util/standardSchema.js';
|
||||
import { isZodRawShape, normalizeRawShapeSchema } from '../../src/util/zodCompat.js';
|
||||
|
||||
describe('isZodRawShape', () => {
|
||||
test('treats empty object as a raw shape (matches v1)', () => {
|
||||
expect(isZodRawShape({})).toBe(true);
|
||||
});
|
||||
test('detects raw shape with zod fields', () => {
|
||||
expect(isZodRawShape({ a: z.string() })).toBe(true);
|
||||
});
|
||||
test('rejects a Standard Schema instance', () => {
|
||||
expect(isZodRawShape(z.object({ a: z.string() }))).toBe(false);
|
||||
});
|
||||
test('rejects a shape with non-Zod Standard Schema fields', () => {
|
||||
const nonZod = { '~standard': { version: 1, vendor: 'arktype', validate: () => ({ value: 'x' }) } };
|
||||
expect(isZodRawShape({ a: nonZod })).toBe(false);
|
||||
});
|
||||
test('rejects a shape with Zod v3 fields (only v4 is wrappable)', () => {
|
||||
expect(isZodRawShape({ a: mockZodV3String() })).toBe(false);
|
||||
});
|
||||
test('rejects non-plain objects with no own-enumerable properties', () => {
|
||||
expect(isZodRawShape([])).toBe(false);
|
||||
expect(isZodRawShape([z.string()])).toBe(false);
|
||||
expect(isZodRawShape(new Date())).toBe(false);
|
||||
expect(isZodRawShape(new Map())).toBe(false);
|
||||
expect(isZodRawShape(/regex/)).toBe(false);
|
||||
});
|
||||
test('accepts a null-prototype plain object', () => {
|
||||
const o = Object.create(null);
|
||||
o.a = z.string();
|
||||
expect(isZodRawShape(o)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// Minimal structural mock of a Zod v3 schema: has `_def.typeName` and
|
||||
// `~standard.vendor === 'zod'` (zod >=3.24), but no `_zod`.
|
||||
function mockZodV3String(): unknown {
|
||||
return {
|
||||
_def: { typeName: 'ZodString', checks: [], coerce: false },
|
||||
'~standard': { version: 1, vendor: 'zod', validate: (v: unknown) => ({ value: v }) },
|
||||
parse: (v: unknown) => v
|
||||
};
|
||||
}
|
||||
|
||||
describe('normalizeRawShapeSchema', () => {
|
||||
test('wraps empty raw shape into z.object({})', () => {
|
||||
const wrapped = normalizeRawShapeSchema({});
|
||||
expect(wrapped).toBeDefined();
|
||||
expect(standardSchemaToJsonSchema(wrapped!, 'input').type).toBe('object');
|
||||
});
|
||||
test('passes through an already-wrapped Standard Schema unchanged', () => {
|
||||
const schema = z.object({ a: z.string() });
|
||||
expect(normalizeRawShapeSchema(schema)).toBe(schema);
|
||||
});
|
||||
test('returns undefined for undefined input', () => {
|
||||
expect(normalizeRawShapeSchema(undefined)).toBeUndefined();
|
||||
});
|
||||
test('throws TypeError for an invalid object that is neither raw shape nor Standard Schema', () => {
|
||||
expect(() => normalizeRawShapeSchema({ a: 'not a zod schema' } as never)).toThrow(TypeError);
|
||||
});
|
||||
test('passes through a Standard Schema without `~standard.jsonSchema` (per-vendor handling deferred to standardSchemaToJsonSchema)', () => {
|
||||
const noJson = { '~standard': { version: 1, vendor: 'x', validate: () => ({ value: {} }) } };
|
||||
expect(normalizeRawShapeSchema(noJson as never)).toBe(noJson);
|
||||
});
|
||||
test('passes through a zod 4.0-4.1 schema so standardSchemaToJsonSchema can apply its z.toJSONSchema fallback', () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const real = z.object({ a: z.string() });
|
||||
// Simulate zod 4.0-4.1: shadow `~standard` with `jsonSchema` removed, keep `_zod` intact.
|
||||
const { jsonSchema: _drop, ...stdNoJson } = real['~standard'] as unknown as Record<string, unknown>;
|
||||
void _drop;
|
||||
Object.defineProperty(real, '~standard', { value: { ...stdNoJson, vendor: 'zod' }, configurable: true });
|
||||
|
||||
const normalized = normalizeRawShapeSchema(real);
|
||||
expect(normalized).toBe(real);
|
||||
const json = standardSchemaToJsonSchema(normalized!, 'input');
|
||||
expect(json.type).toBe('object');
|
||||
expect((json.properties as Record<string, unknown>)?.a).toBeDefined();
|
||||
warn.mockRestore();
|
||||
});
|
||||
test('throws actionable TypeError for a raw shape with Zod v3 fields', () => {
|
||||
expect(() => normalizeRawShapeSchema({ a: mockZodV3String() } as never)).toThrow(/Zod v4 schemas.*Got a Zod v3 field schema/);
|
||||
});
|
||||
test('throws the intended TypeError (not Object.values crash) for null input', () => {
|
||||
expect(() => normalizeRawShapeSchema(null as never)).toThrow(/must be a Standard Schema/);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,19 +1,19 @@
|
|||
import type { StandardSchemaWithJSON } from '@modelcontextprotocol/core';
|
||||
import type { StandardSchemaV1 } from '@modelcontextprotocol/core';
|
||||
|
||||
export const COMPLETABLE_SYMBOL: unique symbol = Symbol.for('mcp.completable');
|
||||
|
||||
export type CompleteCallback<T extends StandardSchemaWithJSON = StandardSchemaWithJSON> = (
|
||||
value: StandardSchemaWithJSON.InferInput<T>,
|
||||
export type CompleteCallback<T extends StandardSchemaV1 = StandardSchemaV1> = (
|
||||
value: StandardSchemaV1.InferInput<T>,
|
||||
context?: {
|
||||
arguments?: Record<string, string>;
|
||||
}
|
||||
) => StandardSchemaWithJSON.InferInput<T>[] | Promise<StandardSchemaWithJSON.InferInput<T>[]>;
|
||||
) => StandardSchemaV1.InferInput<T>[] | Promise<StandardSchemaV1.InferInput<T>[]>;
|
||||
|
||||
export type CompletableMeta<T extends StandardSchemaWithJSON = StandardSchemaWithJSON> = {
|
||||
export type CompletableMeta<T extends StandardSchemaV1 = StandardSchemaV1> = {
|
||||
complete: CompleteCallback<T>;
|
||||
};
|
||||
|
||||
export type CompletableSchema<T extends StandardSchemaWithJSON> = T & {
|
||||
export type CompletableSchema<T extends StandardSchemaV1> = T & {
|
||||
[COMPLETABLE_SYMBOL]: CompletableMeta<T>;
|
||||
};
|
||||
|
||||
|
|
@ -48,7 +48,7 @@ export type CompletableSchema<T extends StandardSchemaWithJSON> = T & {
|
|||
*
|
||||
* @see {@linkcode server/mcp.McpServer.registerPrompt | McpServer.registerPrompt} for using completable schemas in prompt argument definitions
|
||||
*/
|
||||
export function completable<T extends StandardSchemaWithJSON>(schema: T, complete: CompleteCallback<T>): CompletableSchema<T> {
|
||||
export function completable<T extends StandardSchemaV1>(schema: T, complete: CompleteCallback<T>): CompletableSchema<T> {
|
||||
Object.defineProperty(schema as object, COMPLETABLE_SYMBOL, {
|
||||
value: { complete } as CompletableMeta<T>,
|
||||
enumerable: false,
|
||||
|
|
@ -61,14 +61,14 @@ export function completable<T extends StandardSchemaWithJSON>(schema: T, complet
|
|||
/**
|
||||
* Checks if a schema is completable (has completion metadata).
|
||||
*/
|
||||
export function isCompletable(schema: unknown): schema is CompletableSchema<StandardSchemaWithJSON> {
|
||||
export function isCompletable(schema: unknown): schema is CompletableSchema<StandardSchemaV1> {
|
||||
return !!schema && typeof schema === 'object' && COMPLETABLE_SYMBOL in (schema as object);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the completer callback from a completable schema, if it exists.
|
||||
*/
|
||||
export function getCompleter<T extends StandardSchemaWithJSON>(schema: T): CompleteCallback<T> | undefined {
|
||||
export function getCompleter<T extends StandardSchemaV1>(schema: T): CompleteCallback<T> | undefined {
|
||||
const meta = (schema as unknown as { [COMPLETABLE_SYMBOL]?: CompletableMeta<T> })[COMPLETABLE_SYMBOL];
|
||||
return meta?.complete as CompleteCallback<T> | undefined;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import type {
|
|||
import {
|
||||
assertCompleteRequestPrompt,
|
||||
assertCompleteRequestResourceTemplate,
|
||||
normalizeRawShapeSchema,
|
||||
promptArgumentsFromStandardSchema,
|
||||
ProtocolError,
|
||||
ProtocolErrorCode,
|
||||
|
|
@ -38,6 +39,7 @@ import {
|
|||
validateAndWarnToolName,
|
||||
validateStandardSchema
|
||||
} from '@modelcontextprotocol/core';
|
||||
import type * as z from 'zod/v4';
|
||||
|
||||
import type { ToolTaskHandler } from '../experimental/tasks/interfaces.js';
|
||||
import { ExperimentalMcpServerTasks } from '../experimental/tasks/mcpServer.js';
|
||||
|
|
@ -873,6 +875,31 @@ export class McpServer {
|
|||
_meta?: Record<string, unknown>;
|
||||
},
|
||||
cb: ToolCallback<InputArgs>
|
||||
): RegisteredTool;
|
||||
/** @deprecated Wrap with `z.object({...})` instead. Raw-shape form: `inputSchema`/`outputSchema` may be a plain `{ field: z.string() }` record; it is auto-wrapped with `z.object()`. */
|
||||
registerTool<InputArgs extends ZodRawShape, OutputArgs extends ZodRawShape | StandardSchemaWithJSON | undefined = undefined>(
|
||||
name: string,
|
||||
config: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
inputSchema?: InputArgs;
|
||||
outputSchema?: OutputArgs;
|
||||
annotations?: ToolAnnotations;
|
||||
_meta?: Record<string, unknown>;
|
||||
},
|
||||
cb: LegacyToolCallback<InputArgs>
|
||||
): RegisteredTool;
|
||||
registerTool(
|
||||
name: string,
|
||||
config: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
inputSchema?: StandardSchemaWithJSON | ZodRawShape;
|
||||
outputSchema?: StandardSchemaWithJSON | ZodRawShape;
|
||||
annotations?: ToolAnnotations;
|
||||
_meta?: Record<string, unknown>;
|
||||
},
|
||||
cb: ToolCallback<StandardSchemaWithJSON | undefined> | LegacyToolCallback<ZodRawShape>
|
||||
): RegisteredTool {
|
||||
if (this._registeredTools[name]) {
|
||||
throw new Error(`Tool ${name} is already registered`);
|
||||
|
|
@ -884,8 +911,8 @@ export class McpServer {
|
|||
name,
|
||||
title,
|
||||
description,
|
||||
inputSchema,
|
||||
outputSchema,
|
||||
normalizeRawShapeSchema(inputSchema),
|
||||
normalizeRawShapeSchema(outputSchema),
|
||||
annotations,
|
||||
{ taskSupport: 'forbidden' },
|
||||
_meta,
|
||||
|
|
@ -928,6 +955,27 @@ export class McpServer {
|
|||
_meta?: Record<string, unknown>;
|
||||
},
|
||||
cb: PromptCallback<Args>
|
||||
): RegisteredPrompt;
|
||||
/** @deprecated Wrap with `z.object({...})` instead. Raw-shape form: `argsSchema` may be a plain `{ field: z.string() }` record; it is auto-wrapped with `z.object()`. */
|
||||
registerPrompt<Args extends ZodRawShape>(
|
||||
name: string,
|
||||
config: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
argsSchema?: Args;
|
||||
_meta?: Record<string, unknown>;
|
||||
},
|
||||
cb: LegacyPromptCallback<Args>
|
||||
): RegisteredPrompt;
|
||||
registerPrompt(
|
||||
name: string,
|
||||
config: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
argsSchema?: StandardSchemaWithJSON | ZodRawShape;
|
||||
_meta?: Record<string, unknown>;
|
||||
},
|
||||
cb: PromptCallback<StandardSchemaWithJSON> | LegacyPromptCallback<ZodRawShape>
|
||||
): RegisteredPrompt {
|
||||
if (this._registeredPrompts[name]) {
|
||||
throw new Error(`Prompt ${name} is already registered`);
|
||||
|
|
@ -939,7 +987,7 @@ export class McpServer {
|
|||
name,
|
||||
title,
|
||||
description,
|
||||
argsSchema,
|
||||
normalizeRawShapeSchema(argsSchema),
|
||||
cb as PromptCallback<StandardSchemaWithJSON | undefined>,
|
||||
_meta
|
||||
);
|
||||
|
|
@ -1062,6 +1110,26 @@ export class ResourceTemplate {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A plain record of Zod field schemas, e.g. `{ name: z.string() }`. Accepted by
|
||||
* `registerTool`/`registerPrompt` as a shorthand; auto-wrapped with `z.object()`.
|
||||
* Zod schemas only — `z.object()` cannot wrap other Standard Schema libraries.
|
||||
*/
|
||||
export type ZodRawShape = Record<string, z.ZodType>;
|
||||
|
||||
/** Infers the parsed-output type of a {@linkcode ZodRawShape}. */
|
||||
export type InferRawShape<S extends ZodRawShape> = z.infer<z.ZodObject<S>>;
|
||||
|
||||
/** {@linkcode ToolCallback} variant used when `inputSchema` is a {@linkcode ZodRawShape}. */
|
||||
export type LegacyToolCallback<Args extends ZodRawShape | undefined> = Args extends ZodRawShape
|
||||
? (args: InferRawShape<Args>, ctx: ServerContext) => CallToolResult | Promise<CallToolResult>
|
||||
: (ctx: ServerContext) => CallToolResult | Promise<CallToolResult>;
|
||||
|
||||
/** {@linkcode PromptCallback} variant used when `argsSchema` is a {@linkcode ZodRawShape}. */
|
||||
export type LegacyPromptCallback<Args extends ZodRawShape | undefined> = Args extends ZodRawShape
|
||||
? (args: InferRawShape<Args>, ctx: ServerContext) => GetPromptResult | Promise<GetPromptResult>
|
||||
: (ctx: ServerContext) => GetPromptResult | Promise<GetPromptResult>;
|
||||
|
||||
export type BaseToolCallback<
|
||||
SendResultT extends Result,
|
||||
Ctx extends ServerContext,
|
||||
|
|
|
|||
129
packages/server/test/server/mcp.compat.test.ts
Normal file
129
packages/server/test/server/mcp.compat.test.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import type { JSONRPCMessage } from '@modelcontextprotocol/core';
|
||||
import { InMemoryTransport, isStandardSchema, LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/core';
|
||||
import { describe, expect, expectTypeOf, it, vi } from 'vitest';
|
||||
import * as z from 'zod/v4';
|
||||
import { McpServer } from '../../src/index.js';
|
||||
import type { InferRawShape } from '../../src/server/mcp.js';
|
||||
import { completable } from '../../src/server/completable.js';
|
||||
|
||||
describe('registerTool/registerPrompt accept raw Zod shape (auto-wrapped)', () => {
|
||||
it('registerTool accepts a raw shape for inputSchema and auto-wraps it', () => {
|
||||
const server = new McpServer({ name: 't', version: '1.0.0' });
|
||||
|
||||
server.registerTool('a', { inputSchema: { x: z.number() } }, async ({ x }) => ({
|
||||
content: [{ type: 'text' as const, text: String(x) }]
|
||||
}));
|
||||
server.registerTool('b', { inputSchema: { y: z.number() } }, async ({ y }) => ({
|
||||
content: [{ type: 'text' as const, text: String(y) }]
|
||||
}));
|
||||
|
||||
const tools = (server as unknown as { _registeredTools: Record<string, { inputSchema?: unknown }> })._registeredTools;
|
||||
expect(Object.keys(tools)).toEqual(['a', 'b']);
|
||||
// raw shape was wrapped into a Standard Schema (z.object)
|
||||
expect(isStandardSchema(tools['a']?.inputSchema)).toBe(true);
|
||||
});
|
||||
|
||||
it('registerTool accepts a raw shape for outputSchema and auto-wraps it', () => {
|
||||
const server = new McpServer({ name: 't', version: '1.0.0' });
|
||||
|
||||
server.registerTool('out', { inputSchema: { n: z.number() }, outputSchema: { result: z.string() } }, async ({ n }) => ({
|
||||
content: [{ type: 'text' as const, text: String(n) }],
|
||||
structuredContent: { result: String(n) }
|
||||
}));
|
||||
|
||||
const tools = (server as unknown as { _registeredTools: Record<string, { outputSchema?: unknown }> })._registeredTools;
|
||||
expect(isStandardSchema(tools['out']?.outputSchema)).toBe(true);
|
||||
});
|
||||
|
||||
it('registerTool with z.object() inputSchema also works (passthrough, no auto-wrap)', () => {
|
||||
const server = new McpServer({ name: 't', version: '1.0.0' });
|
||||
|
||||
server.registerTool('c', { inputSchema: z.object({ x: z.number() }) }, async ({ x }) => ({
|
||||
content: [{ type: 'text' as const, text: String(x) }]
|
||||
}));
|
||||
|
||||
const tools = (server as unknown as { _registeredTools: Record<string, { inputSchema?: unknown }> })._registeredTools;
|
||||
expect(isStandardSchema(tools['c']?.inputSchema)).toBe(true);
|
||||
});
|
||||
|
||||
it('registerPrompt accepts a raw shape for argsSchema', () => {
|
||||
const server = new McpServer({ name: 't', version: '1.0.0' });
|
||||
|
||||
server.registerPrompt('p', { argsSchema: { topic: z.string() } }, async ({ topic }) => ({
|
||||
messages: [{ role: 'user' as const, content: { type: 'text' as const, text: topic } }]
|
||||
}));
|
||||
|
||||
const prompts = (server as unknown as { _registeredPrompts: Record<string, { argsSchema?: unknown }> })._registeredPrompts;
|
||||
expect(Object.keys(prompts)).toContain('p');
|
||||
expect(isStandardSchema(prompts['p']?.argsSchema)).toBe(true);
|
||||
});
|
||||
|
||||
it('registerPrompt raw shape accepts completable() fields (v1 pattern)', () => {
|
||||
const server = new McpServer({ name: 't', version: '1.0.0' });
|
||||
|
||||
server.registerPrompt(
|
||||
'p',
|
||||
{
|
||||
argsSchema: {
|
||||
language: completable(z.string(), v => ['typescript', 'python'].filter(l => l.startsWith(v)))
|
||||
}
|
||||
},
|
||||
async ({ language }) => ({
|
||||
messages: [{ role: 'user' as const, content: { type: 'text' as const, text: language } }]
|
||||
})
|
||||
);
|
||||
|
||||
const prompts = (server as unknown as { _registeredPrompts: Record<string, { argsSchema?: unknown }> })._registeredPrompts;
|
||||
expect(isStandardSchema(prompts['p']?.argsSchema)).toBe(true);
|
||||
});
|
||||
|
||||
it('callback receives validated, typed args end-to-end via tools/call', async () => {
|
||||
const server = new McpServer({ name: 't', version: '1.0.0' });
|
||||
|
||||
let received: { x: number } | undefined;
|
||||
server.registerTool('echo', { inputSchema: { x: z.number() } }, async args => {
|
||||
received = args;
|
||||
return { content: [{ type: 'text' as const, text: String(args.x) }] };
|
||||
});
|
||||
|
||||
const [client, srv] = InMemoryTransport.createLinkedPair();
|
||||
await server.connect(srv);
|
||||
await client.start();
|
||||
|
||||
const responses: JSONRPCMessage[] = [];
|
||||
client.onmessage = m => responses.push(m);
|
||||
|
||||
await client.send({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: LATEST_PROTOCOL_VERSION,
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'c', version: '1.0.0' }
|
||||
}
|
||||
} as JSONRPCMessage);
|
||||
await client.send({ jsonrpc: '2.0', method: 'notifications/initialized' } as JSONRPCMessage);
|
||||
await client.send({
|
||||
jsonrpc: '2.0',
|
||||
id: 2,
|
||||
method: 'tools/call',
|
||||
params: { name: 'echo', arguments: { x: 7 } }
|
||||
} as JSONRPCMessage);
|
||||
|
||||
await vi.waitFor(() => expect(responses.some(r => 'id' in r && r.id === 2)).toBe(true));
|
||||
|
||||
expect(received).toEqual({ x: 7 });
|
||||
const result = responses.find(r => 'id' in r && r.id === 2) as { result?: { content: Array<{ text: string }> } };
|
||||
expect(result.result?.content[0]?.text).toBe('7');
|
||||
|
||||
await server.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('InferRawShape', () => {
|
||||
it('preserves optionality from .optional() as ?: keys', () => {
|
||||
type S = InferRawShape<{ a: z.ZodString; b: z.ZodOptional<z.ZodString> }>;
|
||||
expectTypeOf<S>().toEqualTypeOf<{ a: string; b?: string | undefined }>();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue