Skip to content

Commit 667fd5e

Browse files
committed
feat: vendor Protocol shim and MCP types, remove SDK dependency from core
Replace @modelcontextprotocol/sdk imports in all core src/ files with vendored implementations: - src/vendor/protocol.ts: Minimal Protocol class (~670 lines) matching the V1 SDK API. Includes notifications/cancelled handler, progress notification routing with timeout reset, auto-registered ping handler, AbortSignal per inbound request, and RequestHandlerExtra construction. - src/vendor/transport.ts: Transport interface matching V1 SDK. - src/vendor/mcp-types.ts: MCP spec types copied from modelcontextprotocol/modelcontextprotocol schema/2025-11-25/schema.ts (pure TypeScript, zero external imports). - src/vendor/mcp-client.ts: McpClient interface replacing direct SDK Client dependency. AppBridge now accepts McpClient instead of Client. SDK Client satisfies this interface structurally. - src/vendor/in-memory-transport.ts: Simple linked transport pair for testing (replaces SDK's InMemoryTransport). - src/generated/mcp-schemas.ts: MCP Zod schemas generated from vendor/mcp-types.ts via ts-to-zod (same pipeline as existing ext-apps schema generation). - scripts/generate-mcp-schemas.ts: New codegen script for MCP schemas. All core src/ files (events.ts, app.ts, app-bridge.ts, types.ts, message-transport.ts, etc.) now import from vendor/ and generated/ instead of @modelcontextprotocol/sdk. SDK remains as devDependency for examples/ and server/ helpers. 276 tests pass, 0 type errors in core src/.
1 parent c822761 commit 667fd5e

21 files changed

Lines changed: 6665 additions & 287 deletions

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,10 @@
4848
],
4949
"scripts": {
5050
"start": "npm run examples:dev",
51+
"generate:mcp-schemas": "tsx scripts/generate-mcp-schemas.ts && prettier --write \"src/generated/mcp-schemas.ts\"",
5152
"generate:schemas": "tsx scripts/generate-schemas.ts && prettier --write \"src/generated/**/*\"",
5253
"sync:snippets": "bun scripts/sync-snippets.ts",
53-
"build": "npm run generate:schemas && npm run sync:snippets && node scripts/run-bun.mjs build.bun.ts && node scripts/link-self.mjs",
54+
"build": "npm run generate:mcp-schemas && npm run generate:schemas && npm run sync:snippets && node scripts/run-bun.mjs build.bun.ts && node scripts/link-self.mjs",
5455
"prepack": "npm run build",
5556
"build:all": "npm run examples:build",
5657
"test": "bun test src examples",

scripts/generate-mcp-schemas.ts

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
/**
2+
* MCP Schema Generation Script
3+
*
4+
* Generates Zod schemas from the vendored MCP spec types (src/vendor/mcp-types.ts)
5+
* using ts-to-zod. The output (src/generated/mcp-schemas.ts) provides schemas like
6+
* ContentBlockSchema, CallToolResultSchema, ToolSchema, etc. that are consumed by
7+
* the main schema generator (generate-schemas.ts) and the rest of the SDK.
8+
*
9+
* Post-processing:
10+
* - Rewrites `import { z } from "zod"` to `import { z } from "zod/v4"`
11+
* - Replaces `z.record().and(z.object({...}))` with `z.object({...}).passthrough()`
12+
* - Adds a header comment noting the source
13+
*
14+
* @see scripts/generate-schemas.ts
15+
*/
16+
17+
import { readFileSync, writeFileSync } from "node:fs";
18+
import { dirname, join } from "node:path";
19+
import { fileURLToPath } from "node:url";
20+
import { generate } from "ts-to-zod";
21+
22+
const __filename = fileURLToPath(import.meta.url);
23+
const __dirname = dirname(__filename);
24+
const PROJECT_ROOT = join(__dirname, "..");
25+
26+
const MCP_TYPES_FILE = join(PROJECT_ROOT, "src", "vendor", "mcp-types.ts");
27+
const GENERATED_DIR = join(PROJECT_ROOT, "src", "generated");
28+
const OUTPUT_FILE = join(GENERATED_DIR, "mcp-schemas.ts");
29+
30+
function main() {
31+
console.log("Generating MCP Zod schemas from vendor/mcp-types.ts...\n");
32+
33+
const sourceText = readFileSync(MCP_TYPES_FILE, "utf-8");
34+
35+
const result = generate({
36+
sourceText,
37+
keepComments: true,
38+
skipParseJSDoc: false,
39+
getSchemaName: (typeName: string) => `${typeName}Schema`,
40+
});
41+
42+
if (result.errors.length > 0) {
43+
console.error("Generation errors:");
44+
for (const error of result.errors) {
45+
console.error(` - ${error}`);
46+
}
47+
process.exit(1);
48+
}
49+
50+
if (result.hasCircularDependencies) {
51+
console.warn("Warning: Circular dependencies detected in MCP types");
52+
}
53+
54+
let content = result.getZodSchemasFile("../vendor/mcp-types.js");
55+
content = postProcess(content);
56+
57+
writeFileSync(OUTPUT_FILE, content, "utf-8");
58+
console.log(`Written: ${OUTPUT_FILE}`);
59+
console.log("\nMCP schema generation complete!");
60+
}
61+
62+
/**
63+
* Post-process generated schemas for project compatibility.
64+
*/
65+
function postProcess(content: string): string {
66+
// 1. Rewrite zod import to zod/v4
67+
content = content.replace(
68+
'import { z } from "zod";',
69+
'import { z } from "zod/v4";',
70+
);
71+
72+
// 2. Replace z.record().and(z.object({...})) with z.object({...}).passthrough()
73+
content = replaceRecordAndWithPassthrough(content);
74+
75+
// 3. Add header comment
76+
content = content.replace(
77+
"// Generated by ts-to-zod",
78+
`// Generated by ts-to-zod from src/vendor/mcp-types.ts
79+
// Source: MCP spec schema/2025-11-25/schema.ts
80+
// Run: npm run generate:mcp-schemas`,
81+
);
82+
83+
return content;
84+
}
85+
86+
/**
87+
* Replace z.record(z.string(), z.unknown()).and(z.object({...})) with z.object({...}).passthrough()
88+
* Uses brace-counting to handle nested objects correctly.
89+
* passthrough() works in both Zod v3 and v4, allowing extra properties.
90+
*/
91+
function replaceRecordAndWithPassthrough(content: string): string {
92+
const pattern = "z.record(z.string(), z.unknown()).and(z.object({";
93+
let result = content;
94+
let startIndex = 0;
95+
96+
while (true) {
97+
const matchStart = result.indexOf(pattern, startIndex);
98+
if (matchStart === -1) break;
99+
100+
// Find the matching closing brace for z.object({
101+
const objectStart = matchStart + pattern.length;
102+
let braceCount = 1;
103+
let i = objectStart;
104+
105+
while (i < result.length && braceCount > 0) {
106+
if (result[i] === "{") braceCount++;
107+
else if (result[i] === "}") braceCount--;
108+
i++;
109+
}
110+
111+
// i now points after the closing } of z.object({...})
112+
// Check if followed by ))
113+
if (result.slice(i, i + 2) === "))") {
114+
const objectContent = result.slice(objectStart, i - 1);
115+
const replacement = `z.object({${objectContent}}).passthrough()`;
116+
result = result.slice(0, matchStart) + replacement + result.slice(i + 2);
117+
startIndex = matchStart + replacement.length;
118+
} else {
119+
startIndex = i;
120+
}
121+
}
122+
123+
return result;
124+
}
125+
126+
main();

scripts/generate-schemas.ts

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,20 +12,22 @@
1212
*
1313
* ts-to-zod generates `import { z } from "zod"`. We rewrite this to
1414
* `import { z } from "zod/v4"` because the generated schemas compose with
15-
* schemas imported from `@modelcontextprotocol/sdk/types.js`, which the SDK
16-
* constructs via `zod/v4`. Mixing schemas from the v3 and v4 APIs at runtime
17-
* fails with errors like `keyValidator._parse is not a function` (v3 internals
18-
* calling into v4 objects, or vice versa). The `zod/v4` subpath is exported by
19-
* both zod 3.25+ and zod 4.x, so the peerDependency range is preserved.
15+
* schemas imported from `./mcp-schemas.js` (generated from vendored MCP types),
16+
* which are also built with `zod/v4`. Mixing schemas from the v3 and v4 APIs
17+
* at runtime fails with errors like `keyValidator._parse is not a function`
18+
* (v3 internals calling into v4 objects, or vice versa). The `zod/v4` subpath
19+
* is exported by both zod 3.25+ and zod 4.x, so the peerDependency range is
20+
* preserved.
2021
*
2122
* ### 2. External Type References (`z.any()` → actual schemas)
2223
*
2324
* **Problem**: ts-to-zod cannot resolve types imported from external packages.
2425
* When it encounters types like `ContentBlock`, `CallToolResult`, `Implementation`,
25-
* `RequestId`, and `Tool` from `@modelcontextprotocol/sdk`, it generates `z.any()`
26+
* `RequestId`, and `Tool` from `./vendor/mcp-types.ts`, it generates `z.any()`
2627
* as a placeholder.
2728
*
28-
* **Solution**: Import the schemas from MCP SDK and remove the z.any() placeholders.
29+
* **Solution**: Import the schemas from `./mcp-schemas.js` (generated from the
30+
* vendored MCP types) and remove the z.any() placeholders.
2931
*
3032
* ### 3. Index Signatures (`z.record().and()` → `z.object().passthrough()`)
3133
*
@@ -69,8 +71,8 @@ const SCHEMA_TEST_OUTPUT_FILE = join(GENERATED_DIR, "schema.test.ts");
6971
const JSON_SCHEMA_OUTPUT_FILE = join(GENERATED_DIR, "schema.json");
7072

7173
/**
72-
* External types from MCP SDK that ts-to-zod can't resolve.
73-
* With PascalCase naming (via getSchemaName), generated placeholders match MCP SDK exports.
74+
* External types from vendored MCP types that ts-to-zod can't resolve.
75+
* With PascalCase naming (via getSchemaName), generated placeholders match mcp-schemas.ts exports.
7476
*/
7577
const EXTERNAL_TYPE_SCHEMAS = [
7678
"ContentBlockSchema",
@@ -193,7 +195,7 @@ function postProcess(content: string): string {
193195
`import { z } from "zod/v4";
194196
import {
195197
${mcpImports},
196-
} from "@modelcontextprotocol/sdk/types.js";`,
198+
} from "./mcp-schemas.js";`,
197199
);
198200

199201
// 2. Remove z.any() placeholders for external types (now imported from MCP SDK)
@@ -213,7 +215,7 @@ import {
213215
content = content.replace(
214216
"// Generated by ts-to-zod",
215217
`// Generated by ts-to-zod
216-
// Post-processed for Zod v3/v4 compatibility and MCP SDK integration
218+
// Post-processed for Zod v3/v4 compatibility and vendored MCP schema integration
217219
// Run: npm run generate:schemas`,
218220
);
219221

src/app-bridge.examples.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -496,6 +496,6 @@ async function AppBridge_connect_withoutMcpClient(
496496
return { content: [] };
497497
};
498498

499-
await bridge.connect(transport);
499+
await bridge.connect(transport as import("./vendor/transport.js").Transport);
500500
//#endregion AppBridge_connect_withoutMcpClient
501501
}

src/app-bridge.test.ts

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
2-
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
3-
import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
4-
import type { ServerCapabilities } from "@modelcontextprotocol/sdk/types.js";
2+
import { InMemoryTransport } from "./vendor/in-memory-transport.js";
3+
import type { McpClient } from "./vendor/mcp-client.js";
4+
import type { ServerCapabilities } from "./vendor/mcp-types.js";
55
import {
66
EmptyResultSchema,
77
ListPromptsResultSchema,
@@ -11,7 +11,7 @@ import {
1111
ReadResourceResultSchema,
1212
ResourceListChangedNotificationSchema,
1313
ToolListChangedNotificationSchema,
14-
} from "@modelcontextprotocol/sdk/types.js";
14+
} from "./generated/mcp-schemas.js";
1515

1616
import { App } from "./app";
1717
import {
@@ -31,11 +31,12 @@ const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
3131
*/
3232
function createMockClient(
3333
serverCapabilities: ServerCapabilities = {},
34-
): Pick<Client, "getServerCapabilities" | "request" | "notification"> {
34+
): McpClient {
3535
return {
3636
getServerCapabilities: () => serverCapabilities,
3737
request: async () => ({}) as never,
3838
notification: async () => {},
39+
setNotificationHandler: () => {},
3940
};
4041
}
4142

@@ -57,7 +58,7 @@ describe("App <-> AppBridge integration", () => {
5758
[appTransport, bridgeTransport] = InMemoryTransport.createLinkedPair();
5859
app = new App(testAppInfo, {}, { autoResize: false });
5960
bridge = new AppBridge(
60-
createMockClient() as Client,
61+
createMockClient(),
6162
testHostInfo,
6263
testHostCapabilities,
6364
);
@@ -118,7 +119,7 @@ describe("App <-> AppBridge integration", () => {
118119
containerDimensions: { width: 800, maxHeight: 600 },
119120
};
120121
const newBridge = new AppBridge(
121-
createMockClient() as Client,
122+
createMockClient(),
122123
testHostInfo,
123124
testHostCapabilities,
124125
{ hostContext: testHostContext },
@@ -262,7 +263,7 @@ describe("App <-> AppBridge integration", () => {
262263
locale: "en-US",
263264
};
264265
const newBridge = new AppBridge(
265-
createMockClient() as Client,
266+
createMockClient(),
266267
testHostInfo,
267268
testHostCapabilities,
268269
{ hostContext: initialContext },
@@ -305,7 +306,7 @@ describe("App <-> AppBridge integration", () => {
305306
locale: "en-US",
306307
};
307308
const newBridge = new AppBridge(
308-
createMockClient() as Client,
309+
createMockClient(),
309310
testHostInfo,
310311
testHostCapabilities,
311312
{ hostContext: initialContext },
@@ -342,7 +343,7 @@ describe("App <-> AppBridge integration", () => {
342343
containerDimensions: { width: 800, maxHeight: 600 },
343344
};
344345
const newBridge = new AppBridge(
345-
createMockClient() as Client,
346+
createMockClient(),
346347
testHostInfo,
347348
testHostCapabilities,
348349
{ hostContext: initialContext },
@@ -1232,7 +1233,7 @@ describe("isToolVisibilityAppOnly", () => {
12321233
[appTransport, bridgeTransport] = InMemoryTransport.createLinkedPair();
12331234
app = new App(testAppInfo, {}, { autoResize: false });
12341235
bridge = new AppBridge(
1235-
createMockClient() as Client,
1236+
createMockClient(),
12361237
testHostInfo,
12371238
testHostCapabilities,
12381239
);
@@ -1365,7 +1366,7 @@ describe("isToolVisibilityAppOnly", () => {
13651366

13661367
it("direct setRequestHandler throws when called twice", () => {
13671368
const bridge2 = new AppBridge(
1368-
createMockClient() as Client,
1369+
createMockClient(),
13691370
testHostInfo,
13701371
testHostCapabilities,
13711372
);

0 commit comments

Comments
 (0)