Skip to content

Make zod types strip instead of passthrough #792

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
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
1 change: 1 addition & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ jobs:
cache: npm

- run: npm ci
- run: npm run check:strict-types
- run: npm run build
- run: npm test
- run: npm run lint
Expand Down
7 changes: 4 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@ We welcome contributions to the Model Context Protocol TypeScript SDK! This docu

1. Create a new branch for your changes
2. Make your changes
3. Run `npm run lint` to ensure code style compliance
4. Run `npm test` to verify all tests pass
5. Submit a pull request
3. If you modify `src/types.ts`, run `npm run generate:strict-types` to update strict types
4. Run `npm run lint` to ensure code style compliance
5. Run `npm test` to verify all tests pass
6. Submit a pull request

## Pull Request Guidelines

Expand Down
63 changes: 63 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -950,6 +950,69 @@ server.registerTool("tool3", ...).disable();
// Only one 'notifications/tools/list_changed' is sent.
```

### Type Safety

The SDK provides type-safe definitions that validate schemas while maintaining protocol compatibility.

```typescript
// Recommended: Use safe types that strip unknown fields
import { ToolSchema } from "@modelcontextprotocol/sdk/strictTypes.js";

// ⚠️ Deprecated: Extensible types will be removed in a future version
import { ToolSchema } from "@modelcontextprotocol/sdk/types.js";
```

**Safe types with .strip():**
```typescript
import { ToolSchema } from "@modelcontextprotocol/sdk/strictTypes.js";

// Unknown fields are automatically removed, not rejected
const tool = ToolSchema.parse({
name: "get-weather",
description: "Get weather",
inputSchema: { type: "object", properties: {} },
customField: "this will be stripped" // ✓ No error, field is removed
});

console.log(tool.customField); // undefined - field was stripped
console.log(tool.name); // "get-weather" - known fields are preserved
```

**Benefits:**
- **Type safety**: Only known fields are included in results
- **Protocol compatibility**: Works seamlessly with extended servers/clients
- **No runtime errors**: Unknown fields are silently removed
- **Forward compatibility**: Your code won't break when servers add new fields

**Migration Guide:**

If you're currently using types.js and need extensibility:
1. Switch to importing from `strictTypes.js`
2. Add any additional fields you need explicitly to your schemas
3. For true extensibility needs, create wrapper schemas that extend the base types

Example migration:
```typescript
// Before (deprecated)
import { ToolSchema } from "@modelcontextprotocol/sdk/types.js";
const tool = { ...baseFields, customField: "value" };

// After (recommended)
import { ToolSchema } from "@modelcontextprotocol/sdk/strictTypes.js";
import { z } from "zod";

// Create your own extended schema
const ExtendedToolSchema = ToolSchema.extend({
customField: z.string()
});
const tool = ExtendedToolSchema.parse({ ...baseFields, customField: "value" });
```

Note: The following fields remain extensible for protocol compatibility:
- `experimental`: For protocol extensions
- `_meta`: For arbitrary metadata
- `properties`: For JSON Schema objects

### Low-Level Server

For more control, you can use the low-level Server class directly:
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@
],
"scripts": {
"fetch:spec-types": "curl -o spec.types.ts https://raw.githubusercontent.com/modelcontextprotocol/modelcontextprotocol/refs/heads/main/schema/draft/schema.ts",
"generate:strict-types": "tsx scripts/generateStrictTypes.ts",
"check:strict-types": "npm run generate:strict-types && git diff --exit-code src/strictTypes.ts || (echo 'Error: strictTypes.ts is out of date. Run npm run generate:strict-types' && exit 1)",
"build": "npm run build:esm && npm run build:cjs",
"build:esm": "mkdir -p dist/esm && echo '{\"type\": \"module\"}' > dist/esm/package.json && tsc -p tsconfig.prod.json",
"build:esm:w": "npm run build:esm -- -w",
Expand Down
84 changes: 84 additions & 0 deletions scripts/generateStrictTypes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
#!/usr/bin/env node
import { readFileSync, writeFileSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';

const __dirname = dirname(fileURLToPath(import.meta.url));

// Read the original types.ts file
const typesPath = join(__dirname, '../src/types.ts');
const strictTypesPath = join(__dirname, '../src/strictTypes.ts');

let content = readFileSync(typesPath, 'utf-8');

// Add header comment
const header = `/**
* Types remove unknown
* properties to maintaining compatibility with protocol extensions.
*
* - Protocol compatoble: Unknown fields from extended implementations are removed, not rejected
* - Forward compatibility: Works with servers/clients that have additional fields
*
* @generated by scripts/generateStrictTypes.ts
*/

`;

// Replace all .passthrough() with .strip()
content = content.replace(/\.passthrough\(\)/g, '.strip()');

// Special handling for experimental and capabilities that should remain open
// These are explicitly designed to be extensible
const patternsToKeepOpen = [
// Keep experimental fields open as they're meant for extensions
/experimental: z\.optional\(z\.object\(\{\}\)\.strip\(\)\)/g,
// Keep _meta fields open as they're meant for arbitrary metadata
/_meta: z\.optional\(z\.object\(\{\}\)\.strip\(\)\)/g,
// Keep JSON Schema properties open as they can have arbitrary fields
/properties: z\.optional\(z\.object\(\{\}\)\.strip\(\)\)/g,
// Keep BaseRequestParamsSchema passthrough for JSON-RPC param compatibility
/const BaseRequestParamsSchema = z\s*\n\s*\.object\([\s\S]*?\)\s*\n\s*\.strip\(\)/g,
// Keep BaseNotificationParamsSchema passthrough for JSON-RPC param compatibility
/const BaseNotificationParamsSchema = z\s*\n\s*\.object\([\s\S]*?\)\s*\n\s*\.strip\(\)/g,
// Keep RequestMetaSchema passthrough for extensibility
/const RequestMetaSchema = z\s*\n\s*\.object\([\s\S]*?\)\s*\n\s*\.strip\(\)/g,
// Keep structuredContent passthrough for tool-specific output
/structuredContent: z\.object\(\{\}\)\.strip\(\)\.optional\(\)/g,
// Keep metadata passthrough for provider-specific data in sampling
/metadata: z\.optional\(z\.object\(\{\}\)\.strip\(\)\)/g,
];

// Revert strip back to passthrough for these special cases
patternsToKeepOpen.forEach(pattern => {
content = content.replace(pattern, (match) =>
match.replace('.strip()', '.passthrough()')
);
});

// Add a comment explaining the difference
const explanation = `
/**
* Note: The following remain open (using .passthrough()):
* - experimental: Designed for protocol extensions
* - _meta: Designed for arbitrary metadata
* - properties: JSON Schema properties that can have arbitrary fields
* - BaseRequestParamsSchema: Required for JSON-RPC param compatibility
* - BaseNotificationParamsSchema: Required for JSON-RPC param compatibility
* - RequestMetaSchema: Required for protocol extensibility
* - structuredContent: Tool-specific output that can have arbitrary fields
* - metadata: Provider-specific metadata in sampling requests
*
* All other objects use .strip() to remove unknown properties while
* maintaining compatibility with extended protocols.
*/
`;

// Insert the explanation after the imports
const importEndIndex = content.lastIndexOf('import');
const importEndLineIndex = content.indexOf('\n', importEndIndex);
content = content.slice(0, importEndLineIndex + 1) + explanation + content.slice(importEndLineIndex + 1);

// Write the strict types file
writeFileSync(strictTypesPath, header + content);

console.log('Generated strictTypes.ts successfully!');
37 changes: 37 additions & 0 deletions src/examples/strictTypesExample.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* Example showing the difference between extensible types and safe types
*
* - Extensible types (types.js): Use .passthrough() - keep all fields
* - Safe types (strictTypes.js): Use .strip() - remove unknown fields
*/

import { ToolSchema as ExtensibleToolSchema } from "../types.js";
import { ToolSchema } from "../strictTypes.js";

const toolData = {
name: "get-weather",
description: "Get weather for a location",
inputSchema: {
type: "object",
properties: {
location: { type: "string" }
}
},
// Extra properties that aren't in the schema
customField: "This is an extension",
};

// With extensible types - ALL fields are preserved
const extensibleTool = ExtensibleToolSchema.parse(toolData);

console.log("Extensible tool keeps ALL properties:");
console.log("- name:", extensibleTool.name);
// Type assertion to access the extra field
console.log("- customField:", (extensibleTool as Record<string, unknown>).customField); // "This is an extension"

// With safe types - unknown fields are silently stripped
const safeTool = ToolSchema.parse(toolData);

console.log("\nSafe tool strips unknown properties:");
// Type assertion to check the field was removed
console.log("- customField:", (safeTool as Record<string, unknown>).customField); // undefined (stripped)
Loading