diff --git a/.changeset/node-codegen-fallback.md b/.changeset/node-codegen-fallback.md new file mode 100644 index 0000000000..7ad3e3b7be --- /dev/null +++ b/.changeset/node-codegen-fallback.md @@ -0,0 +1,6 @@ +--- +'@modelcontextprotocol/server': patch +'@modelcontextprotocol/client': patch +--- + +On Node with code generation from strings disallowed (`--disallow-code-generation-from-strings`), the default JSON Schema validator is now the cf-worker validator. Ajv, the default otherwise, threw an `EvalError` on the first schema, so `fromJsonSchema` and the server and client constructors failed on such hosts. diff --git a/packages/client/src/shimsNode.ts b/packages/client/src/shimsNode.ts index 5c411d731b..dea37df5fb 100644 --- a/packages/client/src/shimsNode.ts +++ b/packages/client/src/shimsNode.ts @@ -3,7 +3,27 @@ * * This file is selected via package.json export conditions when running in Node.js. */ -export { AjvJsonSchemaValidator as DefaultJsonSchemaValidator } from '@modelcontextprotocol/core-internal/validators/ajv'; +import { AjvJsonSchemaValidator } from '@modelcontextprotocol/core-internal/validators/ajv'; +import { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/core-internal/validators/cfWorker'; + +/** + * Ajv compiles validators with `new Function`. A Node process can disallow that + * (`--disallow-code-generation-from-strings`, or a host that sets it), and Ajv + * then throws an `EvalError` on the first schema. Such a process gets the + * validator the workerd and browser shims use, which does not generate code. + */ +function canGenerateCode(): boolean { + try { + new Function(''); + return true; + } catch { + return false; + } +} + +export const DefaultJsonSchemaValidator: typeof AjvJsonSchemaValidator | typeof CfWorkerJsonSchemaValidator = canGenerateCode() + ? AjvJsonSchemaValidator + : CfWorkerJsonSchemaValidator; /** * Whether `fetch()` may throw `TypeError` due to CORS. CORS is a browser-only concept — diff --git a/packages/client/test/client/codegenDisallowed.test.ts b/packages/client/test/client/codegenDisallowed.test.ts new file mode 100644 index 0000000000..0fc77a092a --- /dev/null +++ b/packages/client/test/client/codegenDisallowed.test.ts @@ -0,0 +1,44 @@ +/** + * The node shim's default JSON Schema validator depends on whether the process + * may generate code from strings. A process started with + * `--disallow-code-generation-from-strings` throws an EvalError from + * `new Function`; the test stands that in by replacing the global `Function` + * while the shim module loads. + */ +import { afterEach, describe, expect, test, vi } from 'vitest'; + +const RealFunction = globalThis.Function; + +async function loadShim(): Promise { + vi.resetModules(); + return import('../../src/shimsNode'); +} + +describe('node shim default JSON Schema validator', () => { + afterEach(() => { + globalThis.Function = RealFunction; + }); + + test('is Ajv when code generation is allowed', async () => { + const { DefaultJsonSchemaValidator } = await loadShim(); + // Modules reload per test, so the classes are compared by name. + expect(DefaultJsonSchemaValidator.name).toBe('AjvJsonSchemaValidator'); + }); + + test('is the cf-worker validator when code generation is disallowed, and it validates', async () => { + globalThis.Function = function () { + throw new EvalError('Code generation from strings disallowed for this context'); + } as unknown as FunctionConstructor; + const { DefaultJsonSchemaValidator } = await loadShim(); + globalThis.Function = RealFunction; + + expect(DefaultJsonSchemaValidator.name).toBe('CfWorkerJsonSchemaValidator'); + const check = new DefaultJsonSchemaValidator().getValidator<{ a: number }>({ + type: 'object', + properties: { a: { type: 'number' } }, + required: ['a'] + }); + expect(check({ a: 1 }).valid).toBe(true); + expect(check({ a: 'x' }).valid).toBe(false); + }); +}); diff --git a/packages/server/src/shimsNode.ts b/packages/server/src/shimsNode.ts index 6ec703659a..6e127f5316 100644 --- a/packages/server/src/shimsNode.ts +++ b/packages/server/src/shimsNode.ts @@ -3,5 +3,25 @@ * * This file is selected via package.json export conditions when running in Node.js. */ -export { AjvJsonSchemaValidator as DefaultJsonSchemaValidator } from '@modelcontextprotocol/core-internal/validators/ajv'; +import { AjvJsonSchemaValidator } from '@modelcontextprotocol/core-internal/validators/ajv'; +import { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/core-internal/validators/cfWorker'; + +/** + * Ajv compiles validators with `new Function`. A Node process can disallow that + * (`--disallow-code-generation-from-strings`, or a host that sets it), and Ajv + * then throws an `EvalError` on the first schema. Such a process gets the + * validator the workerd and browser shims use, which does not generate code. + */ +function canGenerateCode(): boolean { + try { + new Function(''); + return true; + } catch { + return false; + } +} + +export const DefaultJsonSchemaValidator: typeof AjvJsonSchemaValidator | typeof CfWorkerJsonSchemaValidator = canGenerateCode() + ? AjvJsonSchemaValidator + : CfWorkerJsonSchemaValidator; export { default as process } from 'node:process'; diff --git a/packages/server/test/server/codegenDisallowed.test.ts b/packages/server/test/server/codegenDisallowed.test.ts new file mode 100644 index 0000000000..0fc77a092a --- /dev/null +++ b/packages/server/test/server/codegenDisallowed.test.ts @@ -0,0 +1,44 @@ +/** + * The node shim's default JSON Schema validator depends on whether the process + * may generate code from strings. A process started with + * `--disallow-code-generation-from-strings` throws an EvalError from + * `new Function`; the test stands that in by replacing the global `Function` + * while the shim module loads. + */ +import { afterEach, describe, expect, test, vi } from 'vitest'; + +const RealFunction = globalThis.Function; + +async function loadShim(): Promise { + vi.resetModules(); + return import('../../src/shimsNode'); +} + +describe('node shim default JSON Schema validator', () => { + afterEach(() => { + globalThis.Function = RealFunction; + }); + + test('is Ajv when code generation is allowed', async () => { + const { DefaultJsonSchemaValidator } = await loadShim(); + // Modules reload per test, so the classes are compared by name. + expect(DefaultJsonSchemaValidator.name).toBe('AjvJsonSchemaValidator'); + }); + + test('is the cf-worker validator when code generation is disallowed, and it validates', async () => { + globalThis.Function = function () { + throw new EvalError('Code generation from strings disallowed for this context'); + } as unknown as FunctionConstructor; + const { DefaultJsonSchemaValidator } = await loadShim(); + globalThis.Function = RealFunction; + + expect(DefaultJsonSchemaValidator.name).toBe('CfWorkerJsonSchemaValidator'); + const check = new DefaultJsonSchemaValidator().getValidator<{ a: number }>({ + type: 'object', + properties: { a: { type: 'number' } }, + required: ['a'] + }); + expect(check({ a: 1 }).valid).toBe(true); + expect(check({ a: 'x' }).valid).toBe(false); + }); +});