Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "toolception",
"version": "0.5.1",
"version": "0.5.2",
"private": false,
"type": "module",
"main": "dist/index.js",
Expand Down
28 changes: 28 additions & 0 deletions src/server/createMcpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
FastifyTransport,
type FastifyTransportOptions,
} from "../http/FastifyTransport.js";
import { z } from "zod";

export interface CreateMcpServerOptions {
catalog: ToolSetCatalog;
Expand All @@ -28,7 +29,34 @@ export interface CreateMcpServerOptions {
configSchema?: object;
}

/**
* Zod schema for validating startup configuration.
* Uses strict mode to reject unknown properties like 'initialToolsets'.
*/
const startupConfigSchema = z
.object({
mode: z.enum(["DYNAMIC", "STATIC"]).optional(),
toolsets: z.union([z.array(z.string()), z.literal("ALL")]).optional(),
})
.strict();

export async function createMcpServer(options: CreateMcpServerOptions) {
// Validate startup configuration if provided
if (options.startup) {
try {
startupConfigSchema.parse(options.startup);
} catch (error) {
if (error instanceof z.ZodError) {
const formatted = error.format();
throw new Error(
`Invalid startup configuration:\n${JSON.stringify(formatted, null, 2)}\n\n` +
`Hint: Common mistake - use "toolsets" not "initialToolsets"`
);
}
throw error;
}
}

const mode: Exclude<Mode, "ALL"> = options.startup?.mode ?? "DYNAMIC";
if (typeof options.createServer !== "function") {
throw new Error("createMcpServer: `createServer` (factory) is required");
Expand Down
79 changes: 79 additions & 0 deletions tests/createMcpServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,4 +206,83 @@ describe("createMcpServer", () => {
const base = f.created[0];
expect(base.calls.filter((n) => n === "testset.test_tool").length).toBe(1);
});

it("rejects invalid startup properties with Zod validation", async () => {
const { createServer } = makeFakeServerFactory();

// Type cast to bypass TypeScript checking (simulates user with loose types)
const invalidOptions = {
catalog: { core: { name: "Core", description: "", tools: [] } },
startup: { mode: "STATIC", initialToolsets: ["core"] }, // Wrong property!
createServer,
} as any;

await expect(createMcpServer(invalidOptions)).rejects.toThrow(
/Invalid startup configuration/
);
});

it("successfully parses valid startup config with 'mode' and 'toolsets' properties", async () => {
const f = makeFakeServerFactory();
const staticCatalog = {
core: {
name: "Core",
description: "",
tools: [
{
name: "ping",
description: "",
inputSchema: {},
handler: async () => ({ content: [{ type: "text", text: "pong" }] }),
},
],
},
} as any;

await expect(
createMcpServer({
catalog: staticCatalog,
startup: { mode: "STATIC", toolsets: ["core"] },
createServer: f.createServer,
})
).resolves.toBeTruthy();

const base = f.created[0];
expect(base.calls.filter((n) => n === "core.ping").length).toBe(1);
});

it("throws a Zod validation error for completely malformed startup config object", async () => {
const { createServer } = makeFakeServerFactory();

// Non-object startup value
const bad1 = {
catalog,
startup: 42 as any,
createServer,
} as any;

// Object with wrong types for both fields
const bad2 = {
catalog,
startup: { mode: 123, toolsets: 555 } as any,
createServer,
} as any;

await expect(createMcpServer(bad1)).rejects.toThrow(/Invalid startup configuration/);
await expect(createMcpServer(bad2)).rejects.toThrow(/Invalid startup configuration/);
});

it("accepts missing startup config without error (defaults to DYNAMIC)", async () => {
const f = makeFakeServerFactory();
await expect(
createMcpServer({
catalog,
createServer: f.createServer,
})
).resolves.toBeTruthy();

// Default behavior in DYNAMIC mode is to register meta-tools
const base = f.created[0];
expect(base.calls.includes("list_tools")).toBe(true);
});
});