Skip to content

Commit 02fa787

Browse files
committed
Add completable wrapper for Zod schemas
1 parent 9b95fa8 commit 02fa787

File tree

2 files changed

+141
-0
lines changed

2 files changed

+141
-0
lines changed

src/server/completable.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { z } from "zod";
2+
import { completable } from "./completable.js";
3+
4+
describe("completable", () => {
5+
it("preserves types and values of underlying schema", () => {
6+
const baseSchema = z.string();
7+
const schema = completable(baseSchema, () => []);
8+
9+
expect(schema.parse("test")).toBe("test");
10+
expect(() => schema.parse(123)).toThrow();
11+
});
12+
13+
it("provides access to completion function", async () => {
14+
const completions = ["foo", "bar", "baz"];
15+
const schema = completable(z.string(), () => completions);
16+
17+
expect(await schema._def.complete("")).toEqual(completions);
18+
});
19+
20+
it("allows async completion functions", async () => {
21+
const completions = ["foo", "bar", "baz"];
22+
const schema = completable(z.string(), async () => completions);
23+
24+
expect(await schema._def.complete("")).toEqual(completions);
25+
});
26+
27+
it("passes current value to completion function", async () => {
28+
const schema = completable(z.string(), (value) => [value + "!"]);
29+
30+
expect(await schema._def.complete("test")).toEqual(["test!"]);
31+
});
32+
33+
it("works with number schemas", async () => {
34+
const schema = completable(z.number(), () => [1, 2, 3]);
35+
36+
expect(schema.parse(1)).toBe(1);
37+
expect(await schema._def.complete(0)).toEqual([1, 2, 3]);
38+
});
39+
40+
it("preserves schema description", () => {
41+
const desc = "test description";
42+
const schema = completable(z.string().describe(desc), () => []);
43+
44+
expect(schema.description).toBe(desc);
45+
});
46+
});

src/server/completable.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import {
2+
ZodTypeAny,
3+
ZodTypeDef,
4+
ZodType,
5+
ParseInput,
6+
ParseReturnType,
7+
RawCreateParams,
8+
ZodErrorMap,
9+
ProcessedCreateParams,
10+
} from "zod";
11+
12+
export enum McpZodTypeKind {
13+
Completable = "McpCompletable",
14+
}
15+
16+
export type CompleteCallback<T extends ZodTypeAny = ZodTypeAny> = (
17+
value: T["_input"],
18+
) => T["_input"][] | Promise<T["_input"][]>;
19+
20+
export interface CompletableDef<T extends ZodTypeAny = ZodTypeAny>
21+
extends ZodTypeDef {
22+
type: T;
23+
complete: CompleteCallback<T>;
24+
typeName: McpZodTypeKind.Completable;
25+
}
26+
27+
export class Completable<T extends ZodTypeAny> extends ZodType<
28+
T["_output"],
29+
CompletableDef<T>,
30+
T["_input"]
31+
> {
32+
_parse(input: ParseInput): ParseReturnType<this["_output"]> {
33+
const { ctx } = this._processInputParams(input);
34+
const data = ctx.data;
35+
return this._def.type._parse({
36+
data,
37+
path: ctx.path,
38+
parent: ctx,
39+
});
40+
}
41+
42+
unwrap() {
43+
return this._def.type;
44+
}
45+
46+
static create = <T extends ZodTypeAny>(
47+
type: T,
48+
params: RawCreateParams & {
49+
complete: CompleteCallback<T>;
50+
},
51+
): Completable<T> => {
52+
return new Completable({
53+
type,
54+
typeName: McpZodTypeKind.Completable,
55+
complete: params.complete,
56+
...processCreateParams(params),
57+
});
58+
};
59+
}
60+
61+
/**
62+
* Wraps a Zod type to provide autocompletion capabilities. Useful for, e.g., prompt arguments in MCP.
63+
*/
64+
export function completable<T extends ZodTypeAny>(
65+
schema: T,
66+
complete: CompleteCallback<T>,
67+
): Completable<T> {
68+
return Completable.create(schema, { ...schema._def, complete });
69+
}
70+
71+
// Not sure why this isn't exported from Zod:
72+
// https://github.com/colinhacks/zod/blob/f7ad26147ba291cb3fb257545972a8e00e767470/src/types.ts#L130
73+
function processCreateParams(params: RawCreateParams): ProcessedCreateParams {
74+
if (!params) return {};
75+
const { errorMap, invalid_type_error, required_error, description } = params;
76+
if (errorMap && (invalid_type_error || required_error)) {
77+
throw new Error(
78+
`Can't use "invalid_type_error" or "required_error" in conjunction with custom error map.`,
79+
);
80+
}
81+
if (errorMap) return { errorMap: errorMap, description };
82+
const customMap: ZodErrorMap = (iss, ctx) => {
83+
const { message } = params;
84+
85+
if (iss.code === "invalid_enum_value") {
86+
return { message: message ?? ctx.defaultError };
87+
}
88+
if (typeof ctx.data === "undefined") {
89+
return { message: message ?? required_error ?? ctx.defaultError };
90+
}
91+
if (iss.code !== "invalid_type") return { message: ctx.defaultError };
92+
return { message: message ?? invalid_type_error ?? ctx.defaultError };
93+
};
94+
return { errorMap: customMap, description };
95+
}

0 commit comments

Comments
 (0)