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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Upgraded `socket.io-parser` to `^4.2.7`. [#1542](https://github.com/sourcebot-dev/sourcebot/pull/1542)
- Upgraded `fast-uri` to `^3.1.5`. [#1541](https://github.com/sourcebot-dev/sourcebot/pull/1541)
- Upgraded `ip-address` to `^10.4.0`. [#1540](https://github.com/sourcebot-dev/sourcebot/pull/1540)
- [EE] Fixed Ask MCP connector tools failing to load when their input schemas use JSON Schema 2019-09 or 2020-12. [#1547](https://github.com/sourcebot-dev/sourcebot/pull/1547)

## [5.1.5] - 2026-07-31

Expand Down
150 changes: 150 additions & 0 deletions packages/web/src/ee/features/chat/mcp/mcpJsonSchemaValidator.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
import { describe, expect, test } from 'vitest';
import {
compileMcpJsonSchemaValidator,
UnsupportedMcpJsonSchemaDialectError,
UnsupportedMcpJsonSchemaFeatureError,
} from './mcpJsonSchemaValidator';

describe('compileMcpJsonSchemaValidator', () => {
test('uses draft-07 when it is explicitly declared', () => {
const validate = compileMcpJsonSchemaValidator({
$schema: 'http://json-schema.org/draft-07/schema#',
type: 'array',
items: [{ type: 'string' }],
additionalItems: false,
});

expect(validate(['valid'])).toBe(true);
expect(validate(['valid', 'extra'])).toBe(false);
});

test('uses draft 2019-09 for its declared meta-schema', () => {
const validate = compileMcpJsonSchemaValidator({
$schema: 'https://json-schema.org/draft/2019-09/schema',
type: 'object',
properties: {
known: { type: 'string' },
},
unevaluatedProperties: false,
});

expect(validate({ known: 'value' })).toBe(true);
expect(validate({ known: 'value', extra: true })).toBe(false);
expect(validate.errors).toEqual(expect.arrayContaining([
expect.objectContaining({ keyword: 'unevaluatedProperties' }),
]));
});

test.each([
['when explicitly declared', 'https://json-schema.org/draft/2020-12/schema'],
['by default when omitted', undefined],
])('uses draft 2020-12 %s', (_name, dialect) => {
const validate = compileMcpJsonSchemaValidator({
...(dialect ? { $schema: dialect } : {}),
type: 'array',
prefixItems: [{ type: 'string' }],
items: false,
});

expect(validate(['valid'])).toBe(true);
expect(validate([42])).toBe(false);
expect(validate(['valid', 'extra'])).toBe(false);
});

test('preserves all-errors validation and permits unknown keywords', () => {
const validate = compileMcpJsonSchemaValidator({
type: 'object',
required: ['first', 'second'],
unknownServerKeyword: true,
});

expect(validate({})).toBe(false);
expect(validate.errors?.filter(error => error.keyword === 'required')).toHaveLength(2);
});

test('does not conflict when separate server schemas reuse the same root ID', () => {
const first = compileMcpJsonSchemaValidator({
$id: 'https://schemas.example.com/tool-input',
type: 'string',
});
const second = compileMcpJsonSchemaValidator({
$id: 'https://schemas.example.com/tool-input',
type: 'number',
});

expect(first('value')).toBe(true);
expect(second(42)).toBe(true);
});

test.each([
'http://json-schema.org/draft-07/schema#',
'https://json-schema.org/draft/2019-09/schema',
'https://json-schema.org/draft/2020-12/schema',
])('does not let a remote $id remove the %s meta-schema', (dialect) => {
const collidingSchema = compileMcpJsonSchemaValidator({
$schema: dialect,
$id: dialect,
type: 'object',
});

expect(collidingSchema({})).toBe(true);
expect(() => compileMcpJsonSchemaValidator({
$schema: dialect,
type: 'object',
})).not.toThrow();
});

test('resolves document-local 2020-12 references after compilation', () => {
const validate = compileMcpJsonSchemaValidator({
$schema: 'https://json-schema.org/draft/2020-12/schema',
$defs: {
identifier: { type: 'string', minLength: 1 },
},
type: 'object',
properties: {
id: { $ref: '#/$defs/identifier' },
},
required: ['id'],
});

expect(validate({ id: 'issue-id' })).toBe(true);
expect(validate({ id: '' })).toBe(false);
});

test('rejects Ajv asynchronous schemas instead of bypassing validation', () => {
expect(() => compileMcpJsonSchemaValidator({
$async: true,
type: 'object',
})).toThrow(UnsupportedMcpJsonSchemaFeatureError);
});

test.each([
'http://json-schema.org/draft-04/schema#',
'https://malicious.example/schema?access_token=secret-token',
])('rejects unsupported dialects without echoing the declaration', (declaredDialect) => {
let thrown: unknown;
try {
compileMcpJsonSchemaValidator({
$schema: declaredDialect,
type: 'object',
});
} catch (error) {
thrown = error;
}

expect(thrown).toBeInstanceOf(UnsupportedMcpJsonSchemaDialectError);
expect(thrown).toMatchObject({
name: 'UnsupportedMcpJsonSchemaDialectError',
reason: 'unsupported_json_schema_dialect',
});
expect((thrown as Error).message).not.toContain(declaredDialect);
expect((thrown as Error).message).not.toContain('secret-token');
});

test('rejects a non-string declared dialect safely', () => {
expect(() => compileMcpJsonSchemaValidator({
$schema: 202012,
type: 'object',
})).toThrow(UnsupportedMcpJsonSchemaDialectError);
});
});
116 changes: 116 additions & 0 deletions packages/web/src/ee/features/chat/mcp/mcpJsonSchemaValidator.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
import Ajv, { type AnySchema, type ErrorObject, type ValidateFunction } from 'ajv';
import Ajv2019 from 'ajv/dist/2019.js';
import Ajv2020 from 'ajv/dist/2020.js';

const AJV_OPTIONS = {
addUsedSchema: false,
allErrors: true,
strict: false,
} as const;

const JSON_SCHEMA_DIALECT = {
DRAFT_07: 'draft-07',
DRAFT_2019_09: '2019-09',
DRAFT_2020_12: '2020-12',
} as const;

type JsonSchemaDialect = typeof JSON_SCHEMA_DIALECT[keyof typeof JSON_SCHEMA_DIALECT];

const draft07Ajv = new Ajv(AJV_OPTIONS);
const draft2019Ajv = new Ajv2019(AJV_OPTIONS);
const draft2020Ajv = new Ajv2020(AJV_OPTIONS);

const DIALECT_BY_META_SCHEMA_URI = new Map<string, JsonSchemaDialect>([
['http://json-schema.org/draft-07/schema', JSON_SCHEMA_DIALECT.DRAFT_07],
['https://json-schema.org/draft/2019-09/schema', JSON_SCHEMA_DIALECT.DRAFT_2019_09],
['https://json-schema.org/draft/2020-12/schema', JSON_SCHEMA_DIALECT.DRAFT_2020_12],
]);

export class UnsupportedMcpJsonSchemaDialectError extends Error {
readonly reason = 'unsupported_json_schema_dialect';

constructor() {
super('MCP tool schema declares an unsupported JSON Schema dialect. Supported dialects are draft-07, 2019-09, and 2020-12.');
this.name = 'UnsupportedMcpJsonSchemaDialectError';
}
}

export class UnsupportedMcpJsonSchemaFeatureError extends Error {
readonly reason = 'unsupported_json_schema_feature';

constructor() {
super('MCP tool schema uses an unsupported JSON Schema extension.');
this.name = 'UnsupportedMcpJsonSchemaFeatureError';
}
}

/**
* Compiles an MCP tool schema with the Ajv implementation for its declared
* JSON Schema dialect. MCP 2025-11-25 defines 2020-12 as the default when a
* schema does not explicitly declare another dialect.
*/
export function compileMcpJsonSchemaValidator(schema: unknown): ValidateFunction {
rejectAsyncSchema(schema);
const dialect = getJsonSchemaDialect(schema);

switch (dialect) {
case JSON_SCHEMA_DIALECT.DRAFT_07:
return compileWithAjv(draft07Ajv, schema);
case JSON_SCHEMA_DIALECT.DRAFT_2019_09:
return compileWithAjv(draft2019Ajv, schema);
case JSON_SCHEMA_DIALECT.DRAFT_2020_12:
return compileWithAjv(draft2020Ajv, schema);
}
}

export function formatMcpJsonSchemaValidationErrors(
errors: ErrorObject[] | null | undefined,
): string {
// Ajv's error representation and formatter are shared across dialects.
return draft2020Ajv.errorsText(errors);
}

function compileWithAjv(
ajv: Ajv | Ajv2019 | Ajv2020,
schema: unknown,
): ValidateFunction {
// addUsedSchema: false prevents remote root $id values from entering Ajv's
// shared schema registry or conflicting across MCP servers.
return ajv.compile(schema as AnySchema);
}

function rejectAsyncSchema(schema: unknown): void {
if (
typeof schema === 'object'
&& schema !== null
&& (schema as Record<string, unknown>).$async === true
) {
// $async is an Ajv extension rather than a JSON Schema keyword. The
// surrounding AI SDK validation contract is synchronous, so accepting
// it would treat a returned Promise as a successful validation.
throw new UnsupportedMcpJsonSchemaFeatureError();
}
}

function getJsonSchemaDialect(schema: unknown): JsonSchemaDialect {
if (typeof schema !== 'object' || schema === null || !Object.prototype.hasOwnProperty.call(schema, '$schema')) {
return JSON_SCHEMA_DIALECT.DRAFT_2020_12;
}

const declaredDialect = (schema as Record<string, unknown>).$schema;
if (typeof declaredDialect !== 'string') {
throw new UnsupportedMcpJsonSchemaDialectError();
}

// An empty URI fragment does not change the selected dialect. Normalizing
// it also accepts both forms used by draft-07 schemas in the wild.
const normalizedDialect = declaredDialect.endsWith('#')
? declaredDialect.slice(0, -1)
: declaredDialect;
const dialect = DIALECT_BY_META_SCHEMA_URI.get(normalizedDialect);
if (!dialect) {
throw new UnsupportedMcpJsonSchemaDialectError();
}

return dialect;
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all \u003cpre\u003e\u003ccode\u003e blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks"); } } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); } })(); (function(){ try { var __m = "github.com"; var __re = new RegExp('^' + "github\\.com" + '
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Upgraded `socket.io-parser` to `^4.2.7`. [#1542](https://github.com/sourcebot-dev/sourcebot/pull/1542)
- Upgraded `fast-uri` to `^3.1.5`. [#1541](https://github.com/sourcebot-dev/sourcebot/pull/1541)
- Upgraded `ip-address` to `^10.4.0`. [#1540](https://github.com/sourcebot-dev/sourcebot/pull/1540)
- [EE] Fixed Ask MCP connector tools failing to load when their input schemas use JSON Schema 2019-09 or 2020-12. [#1547](https://github.com/sourcebot-dev/sourcebot/pull/1547)

## [5.1.5] - 2026-07-31

Expand Down
150 changes: 150 additions & 0 deletions packages/web/src/ee/features/chat/mcp/mcpJsonSchemaValidator.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
import { describe, expect, test } from 'vitest';
import {
compileMcpJsonSchemaValidator,
UnsupportedMcpJsonSchemaDialectError,
UnsupportedMcpJsonSchemaFeatureError,
} from './mcpJsonSchemaValidator';

describe('compileMcpJsonSchemaValidator', () => {
test('uses draft-07 when it is explicitly declared', () => {
const validate = compileMcpJsonSchemaValidator({
$schema: 'http://json-schema.org/draft-07/schema#',
type: 'array',
items: [{ type: 'string' }],
additionalItems: false,
});

expect(validate(['valid'])).toBe(true);
expect(validate(['valid', 'extra'])).toBe(false);
});

test('uses draft 2019-09 for its declared meta-schema', () => {
const validate = compileMcpJsonSchemaValidator({
$schema: 'https://json-schema.org/draft/2019-09/schema',
type: 'object',
properties: {
known: { type: 'string' },
},
unevaluatedProperties: false,
});

expect(validate({ known: 'value' })).toBe(true);
expect(validate({ known: 'value', extra: true })).toBe(false);
expect(validate.errors).toEqual(expect.arrayContaining([
expect.objectContaining({ keyword: 'unevaluatedProperties' }),
]));
});

test.each([
['when explicitly declared', 'https://json-schema.org/draft/2020-12/schema'],
['by default when omitted', undefined],
])('uses draft 2020-12 %s', (_name, dialect) => {
const validate = compileMcpJsonSchemaValidator({
...(dialect ? { $schema: dialect } : {}),
type: 'array',
prefixItems: [{ type: 'string' }],
items: false,
});

expect(validate(['valid'])).toBe(true);
expect(validate([42])).toBe(false);
expect(validate(['valid', 'extra'])).toBe(false);
});

test('preserves all-errors validation and permits unknown keywords', () => {
const validate = compileMcpJsonSchemaValidator({
type: 'object',
required: ['first', 'second'],
unknownServerKeyword: true,
});

expect(validate({})).toBe(false);
expect(validate.errors?.filter(error => error.keyword === 'required')).toHaveLength(2);
});

test('does not conflict when separate server schemas reuse the same root ID', () => {
const first = compileMcpJsonSchemaValidator({
$id: 'https://schemas.example.com/tool-input',
type: 'string',
});
const second = compileMcpJsonSchemaValidator({
$id: 'https://schemas.example.com/tool-input',
type: 'number',
});

expect(first('value')).toBe(true);
expect(second(42)).toBe(true);
});

test.each([
'http://json-schema.org/draft-07/schema#',
'https://json-schema.org/draft/2019-09/schema',
'https://json-schema.org/draft/2020-12/schema',
])('does not let a remote $id remove the %s meta-schema', (dialect) => {
const collidingSchema = compileMcpJsonSchemaValidator({
$schema: dialect,
$id: dialect,
type: 'object',
});

expect(collidingSchema({})).toBe(true);
expect(() => compileMcpJsonSchemaValidator({
$schema: dialect,
type: 'object',
})).not.toThrow();
});

test('resolves document-local 2020-12 references after compilation', () => {
const validate = compileMcpJsonSchemaValidator({
$schema: 'https://json-schema.org/draft/2020-12/schema',
$defs: {
identifier: { type: 'string', minLength: 1 },
},
type: 'object',
properties: {
id: { $ref: '#/$defs/identifier' },
},
required: ['id'],
});

expect(validate({ id: 'issue-id' })).toBe(true);
expect(validate({ id: '' })).toBe(false);
});

test('rejects Ajv asynchronous schemas instead of bypassing validation', () => {
expect(() => compileMcpJsonSchemaValidator({
$async: true,
type: 'object',
})).toThrow(UnsupportedMcpJsonSchemaFeatureError);
});

test.each([
'http://json-schema.org/draft-04/schema#',
'https://malicious.example/schema?access_token=secret-token',
])('rejects unsupported dialects without echoing the declaration', (declaredDialect) => {
let thrown: unknown;
try {
compileMcpJsonSchemaValidator({
$schema: declaredDialect,
type: 'object',
});
} catch (error) {
thrown = error;
}

expect(thrown).toBeInstanceOf(UnsupportedMcpJsonSchemaDialectError);
expect(thrown).toMatchObject({
name: 'UnsupportedMcpJsonSchemaDialectError',
reason: 'unsupported_json_schema_dialect',
});
expect((thrown as Error).message).not.toContain(declaredDialect);
expect((thrown as Error).message).not.toContain('secret-token');
});

test('rejects a non-string declared dialect safely', () => {
expect(() => compileMcpJsonSchemaValidator({
$schema: 202012,
type: 'object',
})).toThrow(UnsupportedMcpJsonSchemaDialectError);
});
});
116 changes: 116 additions & 0 deletions packages/web/src/ee/features/chat/mcp/mcpJsonSchemaValidator.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
import Ajv, { type AnySchema, type ErrorObject, type ValidateFunction } from 'ajv';
import Ajv2019 from 'ajv/dist/2019.js';
import Ajv2020 from 'ajv/dist/2020.js';

const AJV_OPTIONS = {
addUsedSchema: false,
allErrors: true,
strict: false,
} as const;

const JSON_SCHEMA_DIALECT = {
DRAFT_07: 'draft-07',
DRAFT_2019_09: '2019-09',
DRAFT_2020_12: '2020-12',
} as const;

type JsonSchemaDialect = typeof JSON_SCHEMA_DIALECT[keyof typeof JSON_SCHEMA_DIALECT];

const draft07Ajv = new Ajv(AJV_OPTIONS);
const draft2019Ajv = new Ajv2019(AJV_OPTIONS);
const draft2020Ajv = new Ajv2020(AJV_OPTIONS);

const DIALECT_BY_META_SCHEMA_URI = new Map<string, JsonSchemaDialect>([
['http://json-schema.org/draft-07/schema', JSON_SCHEMA_DIALECT.DRAFT_07],
['https://json-schema.org/draft/2019-09/schema', JSON_SCHEMA_DIALECT.DRAFT_2019_09],
['https://json-schema.org/draft/2020-12/schema', JSON_SCHEMA_DIALECT.DRAFT_2020_12],
]);

export class UnsupportedMcpJsonSchemaDialectError extends Error {
readonly reason = 'unsupported_json_schema_dialect';

constructor() {
super('MCP tool schema declares an unsupported JSON Schema dialect. Supported dialects are draft-07, 2019-09, and 2020-12.');
this.name = 'UnsupportedMcpJsonSchemaDialectError';
}
}

export class UnsupportedMcpJsonSchemaFeatureError extends Error {
readonly reason = 'unsupported_json_schema_feature';

constructor() {
super('MCP tool schema uses an unsupported JSON Schema extension.');
this.name = 'UnsupportedMcpJsonSchemaFeatureError';
}
}

/**
* Compiles an MCP tool schema with the Ajv implementation for its declared
* JSON Schema dialect. MCP 2025-11-25 defines 2020-12 as the default when a
* schema does not explicitly declare another dialect.
*/
export function compileMcpJsonSchemaValidator(schema: unknown): ValidateFunction {
rejectAsyncSchema(schema);
const dialect = getJsonSchemaDialect(schema);

switch (dialect) {
case JSON_SCHEMA_DIALECT.DRAFT_07:
return compileWithAjv(draft07Ajv, schema);
case JSON_SCHEMA_DIALECT.DRAFT_2019_09:
return compileWithAjv(draft2019Ajv, schema);
case JSON_SCHEMA_DIALECT.DRAFT_2020_12:
return compileWithAjv(draft2020Ajv, schema);
}
}

export function formatMcpJsonSchemaValidationErrors(
errors: ErrorObject[] | null | undefined,
): string {
// Ajv's error representation and formatter are shared across dialects.
return draft2020Ajv.errorsText(errors);
}

function compileWithAjv(
ajv: Ajv | Ajv2019 | Ajv2020,
schema: unknown,
): ValidateFunction {
// addUsedSchema: false prevents remote root $id values from entering Ajv's
// shared schema registry or conflicting across MCP servers.
return ajv.compile(schema as AnySchema);
}

function rejectAsyncSchema(schema: unknown): void {
if (
typeof schema === 'object'
&& schema !== null
&& (schema as Record<string, unknown>).$async === true
) {
// $async is an Ajv extension rather than a JSON Schema keyword. The
// surrounding AI SDK validation contract is synchronous, so accepting
// it would treat a returned Promise as a successful validation.
throw new UnsupportedMcpJsonSchemaFeatureError();
}
}

function getJsonSchemaDialect(schema: unknown): JsonSchemaDialect {
if (typeof schema !== 'object' || schema === null || !Object.prototype.hasOwnProperty.call(schema, '$schema')) {
return JSON_SCHEMA_DIALECT.DRAFT_2020_12;
}

const declaredDialect = (schema as Record<string, unknown>).$schema;
if (typeof declaredDialect !== 'string') {
throw new UnsupportedMcpJsonSchemaDialectError();
}

// An empty URI fragment does not change the selected dialect. Normalizing
// it also accepts both forms used by draft-07 schemas in the wild.
const normalizedDialect = declaredDialect.endsWith('#')
? declaredDialect.slice(0, -1)
: declaredDialect;
const dialect = DIALECT_BY_META_SCHEMA_URI.get(normalizedDialect);
if (!dialect) {
throw new UnsupportedMcpJsonSchemaDialectError();
}

return dialect;
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Upgraded `socket.io-parser` to `^4.2.7`. [#1542](https://github.com/sourcebot-dev/sourcebot/pull/1542)
- Upgraded `fast-uri` to `^3.1.5`. [#1541](https://github.com/sourcebot-dev/sourcebot/pull/1541)
- Upgraded `ip-address` to `^10.4.0`. [#1540](https://github.com/sourcebot-dev/sourcebot/pull/1540)
- [EE] Fixed Ask MCP connector tools failing to load when their input schemas use JSON Schema 2019-09 or 2020-12. [#1547](https://github.com/sourcebot-dev/sourcebot/pull/1547)

## [5.1.5] - 2026-07-31

Expand Down
150 changes: 150 additions & 0 deletions packages/web/src/ee/features/chat/mcp/mcpJsonSchemaValidator.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
import { describe, expect, test } from 'vitest';
import {
compileMcpJsonSchemaValidator,
UnsupportedMcpJsonSchemaDialectError,
UnsupportedMcpJsonSchemaFeatureError,
} from './mcpJsonSchemaValidator';

describe('compileMcpJsonSchemaValidator', () => {
test('uses draft-07 when it is explicitly declared', () => {
const validate = compileMcpJsonSchemaValidator({
$schema: 'http://json-schema.org/draft-07/schema#',
type: 'array',
items: [{ type: 'string' }],
additionalItems: false,
});

expect(validate(['valid'])).toBe(true);
expect(validate(['valid', 'extra'])).toBe(false);
});

test('uses draft 2019-09 for its declared meta-schema', () => {
const validate = compileMcpJsonSchemaValidator({
$schema: 'https://json-schema.org/draft/2019-09/schema',
type: 'object',
properties: {
known: { type: 'string' },
},
unevaluatedProperties: false,
});

expect(validate({ known: 'value' })).toBe(true);
expect(validate({ known: 'value', extra: true })).toBe(false);
expect(validate.errors).toEqual(expect.arrayContaining([
expect.objectContaining({ keyword: 'unevaluatedProperties' }),
]));
});

test.each([
['when explicitly declared', 'https://json-schema.org/draft/2020-12/schema'],
['by default when omitted', undefined],
])('uses draft 2020-12 %s', (_name, dialect) => {
const validate = compileMcpJsonSchemaValidator({
...(dialect ? { $schema: dialect } : {}),
type: 'array',
prefixItems: [{ type: 'string' }],
items: false,
});

expect(validate(['valid'])).toBe(true);
expect(validate([42])).toBe(false);
expect(validate(['valid', 'extra'])).toBe(false);
});

test('preserves all-errors validation and permits unknown keywords', () => {
const validate = compileMcpJsonSchemaValidator({
type: 'object',
required: ['first', 'second'],
unknownServerKeyword: true,
});

expect(validate({})).toBe(false);
expect(validate.errors?.filter(error => error.keyword === 'required')).toHaveLength(2);
});

test('does not conflict when separate server schemas reuse the same root ID', () => {
const first = compileMcpJsonSchemaValidator({
$id: 'https://schemas.example.com/tool-input',
type: 'string',
});
const second = compileMcpJsonSchemaValidator({
$id: 'https://schemas.example.com/tool-input',
type: 'number',
});

expect(first('value')).toBe(true);
expect(second(42)).toBe(true);
});

test.each([
'http://json-schema.org/draft-07/schema#',
'https://json-schema.org/draft/2019-09/schema',
'https://json-schema.org/draft/2020-12/schema',
])('does not let a remote $id remove the %s meta-schema', (dialect) => {
const collidingSchema = compileMcpJsonSchemaValidator({
$schema: dialect,
$id: dialect,
type: 'object',
});

expect(collidingSchema({})).toBe(true);
expect(() => compileMcpJsonSchemaValidator({
$schema: dialect,
type: 'object',
})).not.toThrow();
});

test('resolves document-local 2020-12 references after compilation', () => {
const validate = compileMcpJsonSchemaValidator({
$schema: 'https://json-schema.org/draft/2020-12/schema',
$defs: {
identifier: { type: 'string', minLength: 1 },
},
type: 'object',
properties: {
id: { $ref: '#/$defs/identifier' },
},
required: ['id'],
});

expect(validate({ id: 'issue-id' })).toBe(true);
expect(validate({ id: '' })).toBe(false);
});

test('rejects Ajv asynchronous schemas instead of bypassing validation', () => {
expect(() => compileMcpJsonSchemaValidator({
$async: true,
type: 'object',
})).toThrow(UnsupportedMcpJsonSchemaFeatureError);
});

test.each([
'http://json-schema.org/draft-04/schema#',
'https://malicious.example/schema?access_token=secret-token',
])('rejects unsupported dialects without echoing the declaration', (declaredDialect) => {
let thrown: unknown;
try {
compileMcpJsonSchemaValidator({
$schema: declaredDialect,
type: 'object',
});
} catch (error) {
thrown = error;
}

expect(thrown).toBeInstanceOf(UnsupportedMcpJsonSchemaDialectError);
expect(thrown).toMatchObject({
name: 'UnsupportedMcpJsonSchemaDialectError',
reason: 'unsupported_json_schema_dialect',
});
expect((thrown as Error).message).not.toContain(declaredDialect);
expect((thrown as Error).message).not.toContain('secret-token');
});

test('rejects a non-string declared dialect safely', () => {
expect(() => compileMcpJsonSchemaValidator({
$schema: 202012,
type: 'object',
})).toThrow(UnsupportedMcpJsonSchemaDialectError);
});
});
116 changes: 116 additions & 0 deletions packages/web/src/ee/features/chat/mcp/mcpJsonSchemaValidator.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
import Ajv, { type AnySchema, type ErrorObject, type ValidateFunction } from 'ajv';
import Ajv2019 from 'ajv/dist/2019.js';
import Ajv2020 from 'ajv/dist/2020.js';

const AJV_OPTIONS = {
addUsedSchema: false,
allErrors: true,
strict: false,
} as const;

const JSON_SCHEMA_DIALECT = {
DRAFT_07: 'draft-07',
DRAFT_2019_09: '2019-09',
DRAFT_2020_12: '2020-12',
} as const;

type JsonSchemaDialect = typeof JSON_SCHEMA_DIALECT[keyof typeof JSON_SCHEMA_DIALECT];

const draft07Ajv = new Ajv(AJV_OPTIONS);
const draft2019Ajv = new Ajv2019(AJV_OPTIONS);
const draft2020Ajv = new Ajv2020(AJV_OPTIONS);

const DIALECT_BY_META_SCHEMA_URI = new Map<string, JsonSchemaDialect>([
['http://json-schema.org/draft-07/schema', JSON_SCHEMA_DIALECT.DRAFT_07],
['https://json-schema.org/draft/2019-09/schema', JSON_SCHEMA_DIALECT.DRAFT_2019_09],
['https://json-schema.org/draft/2020-12/schema', JSON_SCHEMA_DIALECT.DRAFT_2020_12],
]);

export class UnsupportedMcpJsonSchemaDialectError extends Error {
readonly reason = 'unsupported_json_schema_dialect';

constructor() {
super('MCP tool schema declares an unsupported JSON Schema dialect. Supported dialects are draft-07, 2019-09, and 2020-12.');
this.name = 'UnsupportedMcpJsonSchemaDialectError';
}
}

export class UnsupportedMcpJsonSchemaFeatureError extends Error {
readonly reason = 'unsupported_json_schema_feature';

constructor() {
super('MCP tool schema uses an unsupported JSON Schema extension.');
this.name = 'UnsupportedMcpJsonSchemaFeatureError';
}
}

/**
* Compiles an MCP tool schema with the Ajv implementation for its declared
* JSON Schema dialect. MCP 2025-11-25 defines 2020-12 as the default when a
* schema does not explicitly declare another dialect.
*/
export function compileMcpJsonSchemaValidator(schema: unknown): ValidateFunction {
rejectAsyncSchema(schema);
const dialect = getJsonSchemaDialect(schema);

switch (dialect) {
case JSON_SCHEMA_DIALECT.DRAFT_07:
return compileWithAjv(draft07Ajv, schema);
case JSON_SCHEMA_DIALECT.DRAFT_2019_09:
return compileWithAjv(draft2019Ajv, schema);
case JSON_SCHEMA_DIALECT.DRAFT_2020_12:
return compileWithAjv(draft2020Ajv, schema);
}
}

export function formatMcpJsonSchemaValidationErrors(
errors: ErrorObject[] | null | undefined,
): string {
// Ajv's error representation and formatter are shared across dialects.
return draft2020Ajv.errorsText(errors);
}

function compileWithAjv(
ajv: Ajv | Ajv2019 | Ajv2020,
schema: unknown,
): ValidateFunction {
// addUsedSchema: false prevents remote root $id values from entering Ajv's
// shared schema registry or conflicting across MCP servers.
return ajv.compile(schema as AnySchema);
}

function rejectAsyncSchema(schema: unknown): void {
if (
typeof schema === 'object'
&& schema !== null
&& (schema as Record<string, unknown>).$async === true
) {
// $async is an Ajv extension rather than a JSON Schema keyword. The
// surrounding AI SDK validation contract is synchronous, so accepting
// it would treat a returned Promise as a successful validation.
throw new UnsupportedMcpJsonSchemaFeatureError();
}
}

function getJsonSchemaDialect(schema: unknown): JsonSchemaDialect {
if (typeof schema !== 'object' || schema === null || !Object.prototype.hasOwnProperty.call(schema, '$schema')) {
return JSON_SCHEMA_DIALECT.DRAFT_2020_12;
}

const declaredDialect = (schema as Record<string, unknown>).$schema;
if (typeof declaredDialect !== 'string') {
throw new UnsupportedMcpJsonSchemaDialectError();
}

// An empty URI fragment does not change the selected dialect. Normalizing
// it also accepts both forms used by draft-07 schemas in the wild.
const normalizedDialect = declaredDialect.endsWith('#')
? declaredDialect.slice(0, -1)
: declaredDialect;
const dialect = DIALECT_BY_META_SCHEMA_URI.get(normalizedDialect);
if (!dialect) {
throw new UnsupportedMcpJsonSchemaDialectError();
}

return dialect;
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length \u003e 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Upgraded `socket.io-parser` to `^4.2.7`. [#1542](https://github.com/sourcebot-dev/sourcebot/pull/1542)
- Upgraded `fast-uri` to `^3.1.5`. [#1541](https://github.com/sourcebot-dev/sourcebot/pull/1541)
- Upgraded `ip-address` to `^10.4.0`. [#1540](https://github.com/sourcebot-dev/sourcebot/pull/1540)
- [EE] Fixed Ask MCP connector tools failing to load when their input schemas use JSON Schema 2019-09 or 2020-12. [#1547](https://github.com/sourcebot-dev/sourcebot/pull/1547)

## [5.1.5] - 2026-07-31

Expand Down
150 changes: 150 additions & 0 deletions packages/web/src/ee/features/chat/mcp/mcpJsonSchemaValidator.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
import { describe, expect, test } from 'vitest';
import {
compileMcpJsonSchemaValidator,
UnsupportedMcpJsonSchemaDialectError,
UnsupportedMcpJsonSchemaFeatureError,
} from './mcpJsonSchemaValidator';

describe('compileMcpJsonSchemaValidator', () => {
test('uses draft-07 when it is explicitly declared', () => {
const validate = compileMcpJsonSchemaValidator({
$schema: 'http://json-schema.org/draft-07/schema#',
type: 'array',
items: [{ type: 'string' }],
additionalItems: false,
});

expect(validate(['valid'])).toBe(true);
expect(validate(['valid', 'extra'])).toBe(false);
});

test('uses draft 2019-09 for its declared meta-schema', () => {
const validate = compileMcpJsonSchemaValidator({
$schema: 'https://json-schema.org/draft/2019-09/schema',
type: 'object',
properties: {
known: { type: 'string' },
},
unevaluatedProperties: false,
});

expect(validate({ known: 'value' })).toBe(true);
expect(validate({ known: 'value', extra: true })).toBe(false);
expect(validate.errors).toEqual(expect.arrayContaining([
expect.objectContaining({ keyword: 'unevaluatedProperties' }),
]));
});

test.each([
['when explicitly declared', 'https://json-schema.org/draft/2020-12/schema'],
['by default when omitted', undefined],
])('uses draft 2020-12 %s', (_name, dialect) => {
const validate = compileMcpJsonSchemaValidator({
...(dialect ? { $schema: dialect } : {}),
type: 'array',
prefixItems: [{ type: 'string' }],
items: false,
});

expect(validate(['valid'])).toBe(true);
expect(validate([42])).toBe(false);
expect(validate(['valid', 'extra'])).toBe(false);
});

test('preserves all-errors validation and permits unknown keywords', () => {
const validate = compileMcpJsonSchemaValidator({
type: 'object',
required: ['first', 'second'],
unknownServerKeyword: true,
});

expect(validate({})).toBe(false);
expect(validate.errors?.filter(error => error.keyword === 'required')).toHaveLength(2);
});

test('does not conflict when separate server schemas reuse the same root ID', () => {
const first = compileMcpJsonSchemaValidator({
$id: 'https://schemas.example.com/tool-input',
type: 'string',
});
const second = compileMcpJsonSchemaValidator({
$id: 'https://schemas.example.com/tool-input',
type: 'number',
});

expect(first('value')).toBe(true);
expect(second(42)).toBe(true);
});

test.each([
'http://json-schema.org/draft-07/schema#',
'https://json-schema.org/draft/2019-09/schema',
'https://json-schema.org/draft/2020-12/schema',
])('does not let a remote $id remove the %s meta-schema', (dialect) => {
const collidingSchema = compileMcpJsonSchemaValidator({
$schema: dialect,
$id: dialect,
type: 'object',
});

expect(collidingSchema({})).toBe(true);
expect(() => compileMcpJsonSchemaValidator({
$schema: dialect,
type: 'object',
})).not.toThrow();
});

test('resolves document-local 2020-12 references after compilation', () => {
const validate = compileMcpJsonSchemaValidator({
$schema: 'https://json-schema.org/draft/2020-12/schema',
$defs: {
identifier: { type: 'string', minLength: 1 },
},
type: 'object',
properties: {
id: { $ref: '#/$defs/identifier' },
},
required: ['id'],
});

expect(validate({ id: 'issue-id' })).toBe(true);
expect(validate({ id: '' })).toBe(false);
});

test('rejects Ajv asynchronous schemas instead of bypassing validation', () => {
expect(() => compileMcpJsonSchemaValidator({
$async: true,
type: 'object',
})).toThrow(UnsupportedMcpJsonSchemaFeatureError);
});

test.each([
'http://json-schema.org/draft-04/schema#',
'https://malicious.example/schema?access_token=secret-token',
])('rejects unsupported dialects without echoing the declaration', (declaredDialect) => {
let thrown: unknown;
try {
compileMcpJsonSchemaValidator({
$schema: declaredDialect,
type: 'object',
});
} catch (error) {
thrown = error;
}

expect(thrown).toBeInstanceOf(UnsupportedMcpJsonSchemaDialectError);
expect(thrown).toMatchObject({
name: 'UnsupportedMcpJsonSchemaDialectError',
reason: 'unsupported_json_schema_dialect',
});
expect((thrown as Error).message).not.toContain(declaredDialect);
expect((thrown as Error).message).not.toContain('secret-token');
});

test('rejects a non-string declared dialect safely', () => {
expect(() => compileMcpJsonSchemaValidator({
$schema: 202012,
type: 'object',
})).toThrow(UnsupportedMcpJsonSchemaDialectError);
});
});
116 changes: 116 additions & 0 deletions packages/web/src/ee/features/chat/mcp/mcpJsonSchemaValidator.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
import Ajv, { type AnySchema, type ErrorObject, type ValidateFunction } from 'ajv';
import Ajv2019 from 'ajv/dist/2019.js';
import Ajv2020 from 'ajv/dist/2020.js';

const AJV_OPTIONS = {
addUsedSchema: false,
allErrors: true,
strict: false,
} as const;

const JSON_SCHEMA_DIALECT = {
DRAFT_07: 'draft-07',
DRAFT_2019_09: '2019-09',
DRAFT_2020_12: '2020-12',
} as const;

type JsonSchemaDialect = typeof JSON_SCHEMA_DIALECT[keyof typeof JSON_SCHEMA_DIALECT];

const draft07Ajv = new Ajv(AJV_OPTIONS);
const draft2019Ajv = new Ajv2019(AJV_OPTIONS);
const draft2020Ajv = new Ajv2020(AJV_OPTIONS);

const DIALECT_BY_META_SCHEMA_URI = new Map<string, JsonSchemaDialect>([
['http://json-schema.org/draft-07/schema', JSON_SCHEMA_DIALECT.DRAFT_07],
['https://json-schema.org/draft/2019-09/schema', JSON_SCHEMA_DIALECT.DRAFT_2019_09],
['https://json-schema.org/draft/2020-12/schema', JSON_SCHEMA_DIALECT.DRAFT_2020_12],
]);

export class UnsupportedMcpJsonSchemaDialectError extends Error {
readonly reason = 'unsupported_json_schema_dialect';

constructor() {
super('MCP tool schema declares an unsupported JSON Schema dialect. Supported dialects are draft-07, 2019-09, and 2020-12.');
this.name = 'UnsupportedMcpJsonSchemaDialectError';
}
}

export class UnsupportedMcpJsonSchemaFeatureError extends Error {
readonly reason = 'unsupported_json_schema_feature';

constructor() {
super('MCP tool schema uses an unsupported JSON Schema extension.');
this.name = 'UnsupportedMcpJsonSchemaFeatureError';
}
}

/**
* Compiles an MCP tool schema with the Ajv implementation for its declared
* JSON Schema dialect. MCP 2025-11-25 defines 2020-12 as the default when a
* schema does not explicitly declare another dialect.
*/
export function compileMcpJsonSchemaValidator(schema: unknown): ValidateFunction {
rejectAsyncSchema(schema);
const dialect = getJsonSchemaDialect(schema);

switch (dialect) {
case JSON_SCHEMA_DIALECT.DRAFT_07:
return compileWithAjv(draft07Ajv, schema);
case JSON_SCHEMA_DIALECT.DRAFT_2019_09:
return compileWithAjv(draft2019Ajv, schema);
case JSON_SCHEMA_DIALECT.DRAFT_2020_12:
return compileWithAjv(draft2020Ajv, schema);
}
}

export function formatMcpJsonSchemaValidationErrors(
errors: ErrorObject[] | null | undefined,
): string {
// Ajv's error representation and formatter are shared across dialects.
return draft2020Ajv.errorsText(errors);
}

function compileWithAjv(
ajv: Ajv | Ajv2019 | Ajv2020,
schema: unknown,
): ValidateFunction {
// addUsedSchema: false prevents remote root $id values from entering Ajv's
// shared schema registry or conflicting across MCP servers.
return ajv.compile(schema as AnySchema);
}

function rejectAsyncSchema(schema: unknown): void {
if (
typeof schema === 'object'
&& schema !== null
&& (schema as Record<string, unknown>).$async === true
) {
// $async is an Ajv extension rather than a JSON Schema keyword. The
// surrounding AI SDK validation contract is synchronous, so accepting
// it would treat a returned Promise as a successful validation.
throw new UnsupportedMcpJsonSchemaFeatureError();
}
}

function getJsonSchemaDialect(schema: unknown): JsonSchemaDialect {
if (typeof schema !== 'object' || schema === null || !Object.prototype.hasOwnProperty.call(schema, '$schema')) {
return JSON_SCHEMA_DIALECT.DRAFT_2020_12;
}

const declaredDialect = (schema as Record<string, unknown>).$schema;
if (typeof declaredDialect !== 'string') {
throw new UnsupportedMcpJsonSchemaDialectError();
}

// An empty URI fragment does not change the selected dialect. Normalizing
// it also accepts both forms used by draft-07 schemas in the wild.
const normalizedDialect = declaredDialect.endsWith('#')
? declaredDialect.slice(0, -1)
: declaredDialect;
const dialect = DIALECT_BY_META_SCHEMA_URI.get(normalizedDialect);
if (!dialect) {
throw new UnsupportedMcpJsonSchemaDialectError();
}

return dialect;
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Upgraded `socket.io-parser` to `^4.2.7`. [#1542](https://github.com/sourcebot-dev/sourcebot/pull/1542)
- Upgraded `fast-uri` to `^3.1.5`. [#1541](https://github.com/sourcebot-dev/sourcebot/pull/1541)
- Upgraded `ip-address` to `^10.4.0`. [#1540](https://github.com/sourcebot-dev/sourcebot/pull/1540)
- [EE] Fixed Ask MCP connector tools failing to load when their input schemas use JSON Schema 2019-09 or 2020-12. [#1547](https://github.com/sourcebot-dev/sourcebot/pull/1547)

## [5.1.5] - 2026-07-31

Expand Down
150 changes: 150 additions & 0 deletions packages/web/src/ee/features/chat/mcp/mcpJsonSchemaValidator.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
import { describe, expect, test } from 'vitest';
import {
compileMcpJsonSchemaValidator,
UnsupportedMcpJsonSchemaDialectError,
UnsupportedMcpJsonSchemaFeatureError,
} from './mcpJsonSchemaValidator';

describe('compileMcpJsonSchemaValidator', () => {
test('uses draft-07 when it is explicitly declared', () => {
const validate = compileMcpJsonSchemaValidator({
$schema: 'http://json-schema.org/draft-07/schema#',
type: 'array',
items: [{ type: 'string' }],
additionalItems: false,
});

expect(validate(['valid'])).toBe(true);
expect(validate(['valid', 'extra'])).toBe(false);
});

test('uses draft 2019-09 for its declared meta-schema', () => {
const validate = compileMcpJsonSchemaValidator({
$schema: 'https://json-schema.org/draft/2019-09/schema',
type: 'object',
properties: {
known: { type: 'string' },
},
unevaluatedProperties: false,
});

expect(validate({ known: 'value' })).toBe(true);
expect(validate({ known: 'value', extra: true })).toBe(false);
expect(validate.errors).toEqual(expect.arrayContaining([
expect.objectContaining({ keyword: 'unevaluatedProperties' }),
]));
});

test.each([
['when explicitly declared', 'https://json-schema.org/draft/2020-12/schema'],
['by default when omitted', undefined],
])('uses draft 2020-12 %s', (_name, dialect) => {
const validate = compileMcpJsonSchemaValidator({
...(dialect ? { $schema: dialect } : {}),
type: 'array',
prefixItems: [{ type: 'string' }],
items: false,
});

expect(validate(['valid'])).toBe(true);
expect(validate([42])).toBe(false);
expect(validate(['valid', 'extra'])).toBe(false);
});

test('preserves all-errors validation and permits unknown keywords', () => {
const validate = compileMcpJsonSchemaValidator({
type: 'object',
required: ['first', 'second'],
unknownServerKeyword: true,
});

expect(validate({})).toBe(false);
expect(validate.errors?.filter(error => error.keyword === 'required')).toHaveLength(2);
});

test('does not conflict when separate server schemas reuse the same root ID', () => {
const first = compileMcpJsonSchemaValidator({
$id: 'https://schemas.example.com/tool-input',
type: 'string',
});
const second = compileMcpJsonSchemaValidator({
$id: 'https://schemas.example.com/tool-input',
type: 'number',
});

expect(first('value')).toBe(true);
expect(second(42)).toBe(true);
});

test.each([
'http://json-schema.org/draft-07/schema#',
'https://json-schema.org/draft/2019-09/schema',
'https://json-schema.org/draft/2020-12/schema',
])('does not let a remote $id remove the %s meta-schema', (dialect) => {
const collidingSchema = compileMcpJsonSchemaValidator({
$schema: dialect,
$id: dialect,
type: 'object',
});

expect(collidingSchema({})).toBe(true);
expect(() => compileMcpJsonSchemaValidator({
$schema: dialect,
type: 'object',
})).not.toThrow();
});

test('resolves document-local 2020-12 references after compilation', () => {
const validate = compileMcpJsonSchemaValidator({
$schema: 'https://json-schema.org/draft/2020-12/schema',
$defs: {
identifier: { type: 'string', minLength: 1 },
},
type: 'object',
properties: {
id: { $ref: '#/$defs/identifier' },
},
required: ['id'],
});

expect(validate({ id: 'issue-id' })).toBe(true);
expect(validate({ id: '' })).toBe(false);
});

test('rejects Ajv asynchronous schemas instead of bypassing validation', () => {
expect(() => compileMcpJsonSchemaValidator({
$async: true,
type: 'object',
})).toThrow(UnsupportedMcpJsonSchemaFeatureError);
});

test.each([
'http://json-schema.org/draft-04/schema#',
'https://malicious.example/schema?access_token=secret-token',
])('rejects unsupported dialects without echoing the declaration', (declaredDialect) => {
let thrown: unknown;
try {
compileMcpJsonSchemaValidator({
$schema: declaredDialect,
type: 'object',
});
} catch (error) {
thrown = error;
}

expect(thrown).toBeInstanceOf(UnsupportedMcpJsonSchemaDialectError);
expect(thrown).toMatchObject({
name: 'UnsupportedMcpJsonSchemaDialectError',
reason: 'unsupported_json_schema_dialect',
});
expect((thrown as Error).message).not.toContain(declaredDialect);
expect((thrown as Error).message).not.toContain('secret-token');
});

test('rejects a non-string declared dialect safely', () => {
expect(() => compileMcpJsonSchemaValidator({
$schema: 202012,
type: 'object',
})).toThrow(UnsupportedMcpJsonSchemaDialectError);
});
});
116 changes: 116 additions & 0 deletions packages/web/src/ee/features/chat/mcp/mcpJsonSchemaValidator.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
import Ajv, { type AnySchema, type ErrorObject, type ValidateFunction } from 'ajv';
import Ajv2019 from 'ajv/dist/2019.js';
import Ajv2020 from 'ajv/dist/2020.js';

const AJV_OPTIONS = {
addUsedSchema: false,
allErrors: true,
strict: false,
} as const;

const JSON_SCHEMA_DIALECT = {
DRAFT_07: 'draft-07',
DRAFT_2019_09: '2019-09',
DRAFT_2020_12: '2020-12',
} as const;

type JsonSchemaDialect = typeof JSON_SCHEMA_DIALECT[keyof typeof JSON_SCHEMA_DIALECT];

const draft07Ajv = new Ajv(AJV_OPTIONS);
const draft2019Ajv = new Ajv2019(AJV_OPTIONS);
const draft2020Ajv = new Ajv2020(AJV_OPTIONS);

const DIALECT_BY_META_SCHEMA_URI = new Map<string, JsonSchemaDialect>([
['http://json-schema.org/draft-07/schema', JSON_SCHEMA_DIALECT.DRAFT_07],
['https://json-schema.org/draft/2019-09/schema', JSON_SCHEMA_DIALECT.DRAFT_2019_09],
['https://json-schema.org/draft/2020-12/schema', JSON_SCHEMA_DIALECT.DRAFT_2020_12],
]);

export class UnsupportedMcpJsonSchemaDialectError extends Error {
readonly reason = 'unsupported_json_schema_dialect';

constructor() {
super('MCP tool schema declares an unsupported JSON Schema dialect. Supported dialects are draft-07, 2019-09, and 2020-12.');
this.name = 'UnsupportedMcpJsonSchemaDialectError';
}
}

export class UnsupportedMcpJsonSchemaFeatureError extends Error {
readonly reason = 'unsupported_json_schema_feature';

constructor() {
super('MCP tool schema uses an unsupported JSON Schema extension.');
this.name = 'UnsupportedMcpJsonSchemaFeatureError';
}
}

/**
* Compiles an MCP tool schema with the Ajv implementation for its declared
* JSON Schema dialect. MCP 2025-11-25 defines 2020-12 as the default when a
* schema does not explicitly declare another dialect.
*/
export function compileMcpJsonSchemaValidator(schema: unknown): ValidateFunction {
rejectAsyncSchema(schema);
const dialect = getJsonSchemaDialect(schema);

switch (dialect) {
case JSON_SCHEMA_DIALECT.DRAFT_07:
return compileWithAjv(draft07Ajv, schema);
case JSON_SCHEMA_DIALECT.DRAFT_2019_09:
return compileWithAjv(draft2019Ajv, schema);
case JSON_SCHEMA_DIALECT.DRAFT_2020_12:
return compileWithAjv(draft2020Ajv, schema);
}
}

export function formatMcpJsonSchemaValidationErrors(
errors: ErrorObject[] | null | undefined,
): string {
// Ajv's error representation and formatter are shared across dialects.
return draft2020Ajv.errorsText(errors);
}

function compileWithAjv(
ajv: Ajv | Ajv2019 | Ajv2020,
schema: unknown,
): ValidateFunction {
// addUsedSchema: false prevents remote root $id values from entering Ajv's
// shared schema registry or conflicting across MCP servers.
return ajv.compile(schema as AnySchema);
}

function rejectAsyncSchema(schema: unknown): void {
if (
typeof schema === 'object'
&& schema !== null
&& (schema as Record<string, unknown>).$async === true
) {
// $async is an Ajv extension rather than a JSON Schema keyword. The
// surrounding AI SDK validation contract is synchronous, so accepting
// it would treat a returned Promise as a successful validation.
throw new UnsupportedMcpJsonSchemaFeatureError();
}
}

function getJsonSchemaDialect(schema: unknown): JsonSchemaDialect {
if (typeof schema !== 'object' || schema === null || !Object.prototype.hasOwnProperty.call(schema, '$schema')) {
return JSON_SCHEMA_DIALECT.DRAFT_2020_12;
}

const declaredDialect = (schema as Record<string, unknown>).$schema;
if (typeof declaredDialect !== 'string') {
throw new UnsupportedMcpJsonSchemaDialectError();
}

// An empty URI fragment does not change the selected dialect. Normalizing
// it also accepts both forms used by draft-07 schemas in the wild.
const normalizedDialect = declaredDialect.endsWith('#')
? declaredDialect.slice(0, -1)
: declaredDialect;
const dialect = DIALECT_BY_META_SCHEMA_URI.get(normalizedDialect);
if (!dialect) {
throw new UnsupportedMcpJsonSchemaDialectError();
}

return dialect;
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Upgraded `socket.io-parser` to `^4.2.7`. [#1542](https://github.com/sourcebot-dev/sourcebot/pull/1542)
- Upgraded `fast-uri` to `^3.1.5`. [#1541](https://github.com/sourcebot-dev/sourcebot/pull/1541)
- Upgraded `ip-address` to `^10.4.0`. [#1540](https://github.com/sourcebot-dev/sourcebot/pull/1540)
- [EE] Fixed Ask MCP connector tools failing to load when their input schemas use JSON Schema 2019-09 or 2020-12. [#1547](https://github.com/sourcebot-dev/sourcebot/pull/1547)

## [5.1.5] - 2026-07-31

Expand Down
150 changes: 150 additions & 0 deletions packages/web/src/ee/features/chat/mcp/mcpJsonSchemaValidator.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
import { describe, expect, test } from 'vitest';
import {
compileMcpJsonSchemaValidator,
UnsupportedMcpJsonSchemaDialectError,
UnsupportedMcpJsonSchemaFeatureError,
} from './mcpJsonSchemaValidator';

describe('compileMcpJsonSchemaValidator', () => {
test('uses draft-07 when it is explicitly declared', () => {
const validate = compileMcpJsonSchemaValidator({
$schema: 'http://json-schema.org/draft-07/schema#',
type: 'array',
items: [{ type: 'string' }],
additionalItems: false,
});

expect(validate(['valid'])).toBe(true);
expect(validate(['valid', 'extra'])).toBe(false);
});

test('uses draft 2019-09 for its declared meta-schema', () => {
const validate = compileMcpJsonSchemaValidator({
$schema: 'https://json-schema.org/draft/2019-09/schema',
type: 'object',
properties: {
known: { type: 'string' },
},
unevaluatedProperties: false,
});

expect(validate({ known: 'value' })).toBe(true);
expect(validate({ known: 'value', extra: true })).toBe(false);
expect(validate.errors).toEqual(expect.arrayContaining([
expect.objectContaining({ keyword: 'unevaluatedProperties' }),
]));
});

test.each([
['when explicitly declared', 'https://json-schema.org/draft/2020-12/schema'],
['by default when omitted', undefined],
])('uses draft 2020-12 %s', (_name, dialect) => {
const validate = compileMcpJsonSchemaValidator({
...(dialect ? { $schema: dialect } : {}),
type: 'array',
prefixItems: [{ type: 'string' }],
items: false,
});

expect(validate(['valid'])).toBe(true);
expect(validate([42])).toBe(false);
expect(validate(['valid', 'extra'])).toBe(false);
});

test('preserves all-errors validation and permits unknown keywords', () => {
const validate = compileMcpJsonSchemaValidator({
type: 'object',
required: ['first', 'second'],
unknownServerKeyword: true,
});

expect(validate({})).toBe(false);
expect(validate.errors?.filter(error => error.keyword === 'required')).toHaveLength(2);
});

test('does not conflict when separate server schemas reuse the same root ID', () => {
const first = compileMcpJsonSchemaValidator({
$id: 'https://schemas.example.com/tool-input',
type: 'string',
});
const second = compileMcpJsonSchemaValidator({
$id: 'https://schemas.example.com/tool-input',
type: 'number',
});

expect(first('value')).toBe(true);
expect(second(42)).toBe(true);
});

test.each([
'http://json-schema.org/draft-07/schema#',
'https://json-schema.org/draft/2019-09/schema',
'https://json-schema.org/draft/2020-12/schema',
])('does not let a remote $id remove the %s meta-schema', (dialect) => {
const collidingSchema = compileMcpJsonSchemaValidator({
$schema: dialect,
$id: dialect,
type: 'object',
});

expect(collidingSchema({})).toBe(true);
expect(() => compileMcpJsonSchemaValidator({
$schema: dialect,
type: 'object',
})).not.toThrow();
});

test('resolves document-local 2020-12 references after compilation', () => {
const validate = compileMcpJsonSchemaValidator({
$schema: 'https://json-schema.org/draft/2020-12/schema',
$defs: {
identifier: { type: 'string', minLength: 1 },
},
type: 'object',
properties: {
id: { $ref: '#/$defs/identifier' },
},
required: ['id'],
});

expect(validate({ id: 'issue-id' })).toBe(true);
expect(validate({ id: '' })).toBe(false);
});

test('rejects Ajv asynchronous schemas instead of bypassing validation', () => {
expect(() => compileMcpJsonSchemaValidator({
$async: true,
type: 'object',
})).toThrow(UnsupportedMcpJsonSchemaFeatureError);
});

test.each([
'http://json-schema.org/draft-04/schema#',
'https://malicious.example/schema?access_token=secret-token',
])('rejects unsupported dialects without echoing the declaration', (declaredDialect) => {
let thrown: unknown;
try {
compileMcpJsonSchemaValidator({
$schema: declaredDialect,
type: 'object',
});
} catch (error) {
thrown = error;
}

expect(thrown).toBeInstanceOf(UnsupportedMcpJsonSchemaDialectError);
expect(thrown).toMatchObject({
name: 'UnsupportedMcpJsonSchemaDialectError',
reason: 'unsupported_json_schema_dialect',
});
expect((thrown as Error).message).not.toContain(declaredDialect);
expect((thrown as Error).message).not.toContain('secret-token');
});

test('rejects a non-string declared dialect safely', () => {
expect(() => compileMcpJsonSchemaValidator({
$schema: 202012,
type: 'object',
})).toThrow(UnsupportedMcpJsonSchemaDialectError);
});
});
116 changes: 116 additions & 0 deletions packages/web/src/ee/features/chat/mcp/mcpJsonSchemaValidator.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
import Ajv, { type AnySchema, type ErrorObject, type ValidateFunction } from 'ajv';
import Ajv2019 from 'ajv/dist/2019.js';
import Ajv2020 from 'ajv/dist/2020.js';

const AJV_OPTIONS = {
addUsedSchema: false,
allErrors: true,
strict: false,
} as const;

const JSON_SCHEMA_DIALECT = {
DRAFT_07: 'draft-07',
DRAFT_2019_09: '2019-09',
DRAFT_2020_12: '2020-12',
} as const;

type JsonSchemaDialect = typeof JSON_SCHEMA_DIALECT[keyof typeof JSON_SCHEMA_DIALECT];

const draft07Ajv = new Ajv(AJV_OPTIONS);
const draft2019Ajv = new Ajv2019(AJV_OPTIONS);
const draft2020Ajv = new Ajv2020(AJV_OPTIONS);

const DIALECT_BY_META_SCHEMA_URI = new Map<string, JsonSchemaDialect>([
['http://json-schema.org/draft-07/schema', JSON_SCHEMA_DIALECT.DRAFT_07],
['https://json-schema.org/draft/2019-09/schema', JSON_SCHEMA_DIALECT.DRAFT_2019_09],
['https://json-schema.org/draft/2020-12/schema', JSON_SCHEMA_DIALECT.DRAFT_2020_12],
]);

export class UnsupportedMcpJsonSchemaDialectError extends Error {
readonly reason = 'unsupported_json_schema_dialect';

constructor() {
super('MCP tool schema declares an unsupported JSON Schema dialect. Supported dialects are draft-07, 2019-09, and 2020-12.');
this.name = 'UnsupportedMcpJsonSchemaDialectError';
}
}

export class UnsupportedMcpJsonSchemaFeatureError extends Error {
readonly reason = 'unsupported_json_schema_feature';

constructor() {
super('MCP tool schema uses an unsupported JSON Schema extension.');
this.name = 'UnsupportedMcpJsonSchemaFeatureError';
}
}

/**
* Compiles an MCP tool schema with the Ajv implementation for its declared
* JSON Schema dialect. MCP 2025-11-25 defines 2020-12 as the default when a
* schema does not explicitly declare another dialect.
*/
export function compileMcpJsonSchemaValidator(schema: unknown): ValidateFunction {
rejectAsyncSchema(schema);
const dialect = getJsonSchemaDialect(schema);

switch (dialect) {
case JSON_SCHEMA_DIALECT.DRAFT_07:
return compileWithAjv(draft07Ajv, schema);
case JSON_SCHEMA_DIALECT.DRAFT_2019_09:
return compileWithAjv(draft2019Ajv, schema);
case JSON_SCHEMA_DIALECT.DRAFT_2020_12:
return compileWithAjv(draft2020Ajv, schema);
}
}

export function formatMcpJsonSchemaValidationErrors(
errors: ErrorObject[] | null | undefined,
): string {
// Ajv's error representation and formatter are shared across dialects.
return draft2020Ajv.errorsText(errors);
}

function compileWithAjv(
ajv: Ajv | Ajv2019 | Ajv2020,
schema: unknown,
): ValidateFunction {
// addUsedSchema: false prevents remote root $id values from entering Ajv's
// shared schema registry or conflicting across MCP servers.
return ajv.compile(schema as AnySchema);
}

function rejectAsyncSchema(schema: unknown): void {
if (
typeof schema === 'object'
&& schema !== null
&& (schema as Record<string, unknown>).$async === true
) {
// $async is an Ajv extension rather than a JSON Schema keyword. The
// surrounding AI SDK validation contract is synchronous, so accepting
// it would treat a returned Promise as a successful validation.
throw new UnsupportedMcpJsonSchemaFeatureError();
}
}

function getJsonSchemaDialect(schema: unknown): JsonSchemaDialect {
if (typeof schema !== 'object' || schema === null || !Object.prototype.hasOwnProperty.call(schema, '$schema')) {
return JSON_SCHEMA_DIALECT.DRAFT_2020_12;
}

const declaredDialect = (schema as Record<string, unknown>).$schema;
if (typeof declaredDialect !== 'string') {
throw new UnsupportedMcpJsonSchemaDialectError();
}

// An empty URI fragment does not change the selected dialect. Normalizing
// it also accepts both forms used by draft-07 schemas in the wild.
const normalizedDialect = declaredDialect.endsWith('#')
? declaredDialect.slice(0, -1)
: declaredDialect;
const dialect = DIALECT_BY_META_SCHEMA_URI.get(normalizedDialect);
if (!dialect) {
throw new UnsupportedMcpJsonSchemaDialectError();
}

return dialect;
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Upgraded `socket.io-parser` to `^4.2.7`. [#1542](https://github.com/sourcebot-dev/sourcebot/pull/1542)
- Upgraded `fast-uri` to `^3.1.5`. [#1541](https://github.com/sourcebot-dev/sourcebot/pull/1541)
- Upgraded `ip-address` to `^10.4.0`. [#1540](https://github.com/sourcebot-dev/sourcebot/pull/1540)
- [EE] Fixed Ask MCP connector tools failing to load when their input schemas use JSON Schema 2019-09 or 2020-12. [#1547](https://github.com/sourcebot-dev/sourcebot/pull/1547)

## [5.1.5] - 2026-07-31

Expand Down
150 changes: 150 additions & 0 deletions packages/web/src/ee/features/chat/mcp/mcpJsonSchemaValidator.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
import { describe, expect, test } from 'vitest';
import {
compileMcpJsonSchemaValidator,
UnsupportedMcpJsonSchemaDialectError,
UnsupportedMcpJsonSchemaFeatureError,
} from './mcpJsonSchemaValidator';

describe('compileMcpJsonSchemaValidator', () => {
test('uses draft-07 when it is explicitly declared', () => {
const validate = compileMcpJsonSchemaValidator({
$schema: 'http://json-schema.org/draft-07/schema#',
type: 'array',
items: [{ type: 'string' }],
additionalItems: false,
});

expect(validate(['valid'])).toBe(true);
expect(validate(['valid', 'extra'])).toBe(false);
});

test('uses draft 2019-09 for its declared meta-schema', () => {
const validate = compileMcpJsonSchemaValidator({
$schema: 'https://json-schema.org/draft/2019-09/schema',
type: 'object',
properties: {
known: { type: 'string' },
},
unevaluatedProperties: false,
});

expect(validate({ known: 'value' })).toBe(true);
expect(validate({ known: 'value', extra: true })).toBe(false);
expect(validate.errors).toEqual(expect.arrayContaining([
expect.objectContaining({ keyword: 'unevaluatedProperties' }),
]));
});

test.each([
['when explicitly declared', 'https://json-schema.org/draft/2020-12/schema'],
['by default when omitted', undefined],
])('uses draft 2020-12 %s', (_name, dialect) => {
const validate = compileMcpJsonSchemaValidator({
...(dialect ? { $schema: dialect } : {}),
type: 'array',
prefixItems: [{ type: 'string' }],
items: false,
});

expect(validate(['valid'])).toBe(true);
expect(validate([42])).toBe(false);
expect(validate(['valid', 'extra'])).toBe(false);
});

test('preserves all-errors validation and permits unknown keywords', () => {
const validate = compileMcpJsonSchemaValidator({
type: 'object',
required: ['first', 'second'],
unknownServerKeyword: true,
});

expect(validate({})).toBe(false);
expect(validate.errors?.filter(error => error.keyword === 'required')).toHaveLength(2);
});

test('does not conflict when separate server schemas reuse the same root ID', () => {
const first = compileMcpJsonSchemaValidator({
$id: 'https://schemas.example.com/tool-input',
type: 'string',
});
const second = compileMcpJsonSchemaValidator({
$id: 'https://schemas.example.com/tool-input',
type: 'number',
});

expect(first('value')).toBe(true);
expect(second(42)).toBe(true);
});

test.each([
'http://json-schema.org/draft-07/schema#',
'https://json-schema.org/draft/2019-09/schema',
'https://json-schema.org/draft/2020-12/schema',
])('does not let a remote $id remove the %s meta-schema', (dialect) => {
const collidingSchema = compileMcpJsonSchemaValidator({
$schema: dialect,
$id: dialect,
type: 'object',
});

expect(collidingSchema({})).toBe(true);
expect(() => compileMcpJsonSchemaValidator({
$schema: dialect,
type: 'object',
})).not.toThrow();
});

test('resolves document-local 2020-12 references after compilation', () => {
const validate = compileMcpJsonSchemaValidator({
$schema: 'https://json-schema.org/draft/2020-12/schema',
$defs: {
identifier: { type: 'string', minLength: 1 },
},
type: 'object',
properties: {
id: { $ref: '#/$defs/identifier' },
},
required: ['id'],
});

expect(validate({ id: 'issue-id' })).toBe(true);
expect(validate({ id: '' })).toBe(false);
});

test('rejects Ajv asynchronous schemas instead of bypassing validation', () => {
expect(() => compileMcpJsonSchemaValidator({
$async: true,
type: 'object',
})).toThrow(UnsupportedMcpJsonSchemaFeatureError);
});

test.each([
'http://json-schema.org/draft-04/schema#',
'https://malicious.example/schema?access_token=secret-token',
])('rejects unsupported dialects without echoing the declaration', (declaredDialect) => {
let thrown: unknown;
try {
compileMcpJsonSchemaValidator({
$schema: declaredDialect,
type: 'object',
});
} catch (error) {
thrown = error;
}

expect(thrown).toBeInstanceOf(UnsupportedMcpJsonSchemaDialectError);
expect(thrown).toMatchObject({
name: 'UnsupportedMcpJsonSchemaDialectError',
reason: 'unsupported_json_schema_dialect',
});
expect((thrown as Error).message).not.toContain(declaredDialect);
expect((thrown as Error).message).not.toContain('secret-token');
});

test('rejects a non-string declared dialect safely', () => {
expect(() => compileMcpJsonSchemaValidator({
$schema: 202012,
type: 'object',
})).toThrow(UnsupportedMcpJsonSchemaDialectError);
});
});
116 changes: 116 additions & 0 deletions packages/web/src/ee/features/chat/mcp/mcpJsonSchemaValidator.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
import Ajv, { type AnySchema, type ErrorObject, type ValidateFunction } from 'ajv';
import Ajv2019 from 'ajv/dist/2019.js';
import Ajv2020 from 'ajv/dist/2020.js';

const AJV_OPTIONS = {
addUsedSchema: false,
allErrors: true,
strict: false,
} as const;

const JSON_SCHEMA_DIALECT = {
DRAFT_07: 'draft-07',
DRAFT_2019_09: '2019-09',
DRAFT_2020_12: '2020-12',
} as const;

type JsonSchemaDialect = typeof JSON_SCHEMA_DIALECT[keyof typeof JSON_SCHEMA_DIALECT];

const draft07Ajv = new Ajv(AJV_OPTIONS);
const draft2019Ajv = new Ajv2019(AJV_OPTIONS);
const draft2020Ajv = new Ajv2020(AJV_OPTIONS);

const DIALECT_BY_META_SCHEMA_URI = new Map<string, JsonSchemaDialect>([
['http://json-schema.org/draft-07/schema', JSON_SCHEMA_DIALECT.DRAFT_07],
['https://json-schema.org/draft/2019-09/schema', JSON_SCHEMA_DIALECT.DRAFT_2019_09],
['https://json-schema.org/draft/2020-12/schema', JSON_SCHEMA_DIALECT.DRAFT_2020_12],
]);

export class UnsupportedMcpJsonSchemaDialectError extends Error {
readonly reason = 'unsupported_json_schema_dialect';

constructor() {
super('MCP tool schema declares an unsupported JSON Schema dialect. Supported dialects are draft-07, 2019-09, and 2020-12.');
this.name = 'UnsupportedMcpJsonSchemaDialectError';
}
}

export class UnsupportedMcpJsonSchemaFeatureError extends Error {
readonly reason = 'unsupported_json_schema_feature';

constructor() {
super('MCP tool schema uses an unsupported JSON Schema extension.');
this.name = 'UnsupportedMcpJsonSchemaFeatureError';
}
}

/**
* Compiles an MCP tool schema with the Ajv implementation for its declared
* JSON Schema dialect. MCP 2025-11-25 defines 2020-12 as the default when a
* schema does not explicitly declare another dialect.
*/
export function compileMcpJsonSchemaValidator(schema: unknown): ValidateFunction {
rejectAsyncSchema(schema);
const dialect = getJsonSchemaDialect(schema);

switch (dialect) {
case JSON_SCHEMA_DIALECT.DRAFT_07:
return compileWithAjv(draft07Ajv, schema);
case JSON_SCHEMA_DIALECT.DRAFT_2019_09:
return compileWithAjv(draft2019Ajv, schema);
case JSON_SCHEMA_DIALECT.DRAFT_2020_12:
return compileWithAjv(draft2020Ajv, schema);
}
}

export function formatMcpJsonSchemaValidationErrors(
errors: ErrorObject[] | null | undefined,
): string {
// Ajv's error representation and formatter are shared across dialects.
return draft2020Ajv.errorsText(errors);
}

function compileWithAjv(
ajv: Ajv | Ajv2019 | Ajv2020,
schema: unknown,
): ValidateFunction {
// addUsedSchema: false prevents remote root $id values from entering Ajv's
// shared schema registry or conflicting across MCP servers.
return ajv.compile(schema as AnySchema);
}

function rejectAsyncSchema(schema: unknown): void {
if (
typeof schema === 'object'
&& schema !== null
&& (schema as Record<string, unknown>).$async === true
) {
// $async is an Ajv extension rather than a JSON Schema keyword. The
// surrounding AI SDK validation contract is synchronous, so accepting
// it would treat a returned Promise as a successful validation.
throw new UnsupportedMcpJsonSchemaFeatureError();
}
}

function getJsonSchemaDialect(schema: unknown): JsonSchemaDialect {
if (typeof schema !== 'object' || schema === null || !Object.prototype.hasOwnProperty.call(schema, '$schema')) {
return JSON_SCHEMA_DIALECT.DRAFT_2020_12;
}

const declaredDialect = (schema as Record<string, unknown>).$schema;
if (typeof declaredDialect !== 'string') {
throw new UnsupportedMcpJsonSchemaDialectError();
}

// An empty URI fragment does not change the selected dialect. Normalizing
// it also accepts both forms used by draft-07 schemas in the wild.
const normalizedDialect = declaredDialect.endsWith('#')
? declaredDialect.slice(0, -1)
: declaredDialect;
const dialect = DIALECT_BY_META_SCHEMA_URI.get(normalizedDialect);
if (!dialect) {
throw new UnsupportedMcpJsonSchemaDialectError();
}

return dialect;
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Upgraded `socket.io-parser` to `^4.2.7`. [#1542](https://github.com/sourcebot-dev/sourcebot/pull/1542)
- Upgraded `fast-uri` to `^3.1.5`. [#1541](https://github.com/sourcebot-dev/sourcebot/pull/1541)
- Upgraded `ip-address` to `^10.4.0`. [#1540](https://github.com/sourcebot-dev/sourcebot/pull/1540)
- [EE] Fixed Ask MCP connector tools failing to load when their input schemas use JSON Schema 2019-09 or 2020-12. [#1547](https://github.com/sourcebot-dev/sourcebot/pull/1547)

## [5.1.5] - 2026-07-31

Expand Down
150 changes: 150 additions & 0 deletions packages/web/src/ee/features/chat/mcp/mcpJsonSchemaValidator.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
import { describe, expect, test } from 'vitest';
import {
compileMcpJsonSchemaValidator,
UnsupportedMcpJsonSchemaDialectError,
UnsupportedMcpJsonSchemaFeatureError,
} from './mcpJsonSchemaValidator';

describe('compileMcpJsonSchemaValidator', () => {
test('uses draft-07 when it is explicitly declared', () => {
const validate = compileMcpJsonSchemaValidator({
$schema: 'http://json-schema.org/draft-07/schema#',
type: 'array',
items: [{ type: 'string' }],
additionalItems: false,
});

expect(validate(['valid'])).toBe(true);
expect(validate(['valid', 'extra'])).toBe(false);
});

test('uses draft 2019-09 for its declared meta-schema', () => {
const validate = compileMcpJsonSchemaValidator({
$schema: 'https://json-schema.org/draft/2019-09/schema',
type: 'object',
properties: {
known: { type: 'string' },
},
unevaluatedProperties: false,
});

expect(validate({ known: 'value' })).toBe(true);
expect(validate({ known: 'value', extra: true })).toBe(false);
expect(validate.errors).toEqual(expect.arrayContaining([
expect.objectContaining({ keyword: 'unevaluatedProperties' }),
]));
});

test.each([
['when explicitly declared', 'https://json-schema.org/draft/2020-12/schema'],
['by default when omitted', undefined],
])('uses draft 2020-12 %s', (_name, dialect) => {
const validate = compileMcpJsonSchemaValidator({
...(dialect ? { $schema: dialect } : {}),
type: 'array',
prefixItems: [{ type: 'string' }],
items: false,
});

expect(validate(['valid'])).toBe(true);
expect(validate([42])).toBe(false);
expect(validate(['valid', 'extra'])).toBe(false);
});

test('preserves all-errors validation and permits unknown keywords', () => {
const validate = compileMcpJsonSchemaValidator({
type: 'object',
required: ['first', 'second'],
unknownServerKeyword: true,
});

expect(validate({})).toBe(false);
expect(validate.errors?.filter(error => error.keyword === 'required')).toHaveLength(2);
});

test('does not conflict when separate server schemas reuse the same root ID', () => {
const first = compileMcpJsonSchemaValidator({
$id: 'https://schemas.example.com/tool-input',
type: 'string',
});
const second = compileMcpJsonSchemaValidator({
$id: 'https://schemas.example.com/tool-input',
type: 'number',
});

expect(first('value')).toBe(true);
expect(second(42)).toBe(true);
});

test.each([
'http://json-schema.org/draft-07/schema#',
'https://json-schema.org/draft/2019-09/schema',
'https://json-schema.org/draft/2020-12/schema',
])('does not let a remote $id remove the %s meta-schema', (dialect) => {
const collidingSchema = compileMcpJsonSchemaValidator({
$schema: dialect,
$id: dialect,
type: 'object',
});

expect(collidingSchema({})).toBe(true);
expect(() => compileMcpJsonSchemaValidator({
$schema: dialect,
type: 'object',
})).not.toThrow();
});

test('resolves document-local 2020-12 references after compilation', () => {
const validate = compileMcpJsonSchemaValidator({
$schema: 'https://json-schema.org/draft/2020-12/schema',
$defs: {
identifier: { type: 'string', minLength: 1 },
},
type: 'object',
properties: {
id: { $ref: '#/$defs/identifier' },
},
required: ['id'],
});

expect(validate({ id: 'issue-id' })).toBe(true);
expect(validate({ id: '' })).toBe(false);
});

test('rejects Ajv asynchronous schemas instead of bypassing validation', () => {
expect(() => compileMcpJsonSchemaValidator({
$async: true,
type: 'object',
})).toThrow(UnsupportedMcpJsonSchemaFeatureError);
});

test.each([
'http://json-schema.org/draft-04/schema#',
'https://malicious.example/schema?access_token=secret-token',
])('rejects unsupported dialects without echoing the declaration', (declaredDialect) => {
let thrown: unknown;
try {
compileMcpJsonSchemaValidator({
$schema: declaredDialect,
type: 'object',
});
} catch (error) {
thrown = error;
}

expect(thrown).toBeInstanceOf(UnsupportedMcpJsonSchemaDialectError);
expect(thrown).toMatchObject({
name: 'UnsupportedMcpJsonSchemaDialectError',
reason: 'unsupported_json_schema_dialect',
});
expect((thrown as Error).message).not.toContain(declaredDialect);
expect((thrown as Error).message).not.toContain('secret-token');
});

test('rejects a non-string declared dialect safely', () => {
expect(() => compileMcpJsonSchemaValidator({
$schema: 202012,
type: 'object',
})).toThrow(UnsupportedMcpJsonSchemaDialectError);
});
});
116 changes: 116 additions & 0 deletions packages/web/src/ee/features/chat/mcp/mcpJsonSchemaValidator.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
import Ajv, { type AnySchema, type ErrorObject, type ValidateFunction } from 'ajv';
import Ajv2019 from 'ajv/dist/2019.js';
import Ajv2020 from 'ajv/dist/2020.js';

const AJV_OPTIONS = {
addUsedSchema: false,
allErrors: true,
strict: false,
} as const;

const JSON_SCHEMA_DIALECT = {
DRAFT_07: 'draft-07',
DRAFT_2019_09: '2019-09',
DRAFT_2020_12: '2020-12',
} as const;

type JsonSchemaDialect = typeof JSON_SCHEMA_DIALECT[keyof typeof JSON_SCHEMA_DIALECT];

const draft07Ajv = new Ajv(AJV_OPTIONS);
const draft2019Ajv = new Ajv2019(AJV_OPTIONS);
const draft2020Ajv = new Ajv2020(AJV_OPTIONS);

const DIALECT_BY_META_SCHEMA_URI = new Map<string, JsonSchemaDialect>([
['http://json-schema.org/draft-07/schema', JSON_SCHEMA_DIALECT.DRAFT_07],
['https://json-schema.org/draft/2019-09/schema', JSON_SCHEMA_DIALECT.DRAFT_2019_09],
['https://json-schema.org/draft/2020-12/schema', JSON_SCHEMA_DIALECT.DRAFT_2020_12],
]);

export class UnsupportedMcpJsonSchemaDialectError extends Error {
readonly reason = 'unsupported_json_schema_dialect';

constructor() {
super('MCP tool schema declares an unsupported JSON Schema dialect. Supported dialects are draft-07, 2019-09, and 2020-12.');
this.name = 'UnsupportedMcpJsonSchemaDialectError';
}
}

export class UnsupportedMcpJsonSchemaFeatureError extends Error {
readonly reason = 'unsupported_json_schema_feature';

constructor() {
super('MCP tool schema uses an unsupported JSON Schema extension.');
this.name = 'UnsupportedMcpJsonSchemaFeatureError';
}
}

/**
* Compiles an MCP tool schema with the Ajv implementation for its declared
* JSON Schema dialect. MCP 2025-11-25 defines 2020-12 as the default when a
* schema does not explicitly declare another dialect.
*/
export function compileMcpJsonSchemaValidator(schema: unknown): ValidateFunction {
rejectAsyncSchema(schema);
const dialect = getJsonSchemaDialect(schema);

switch (dialect) {
case JSON_SCHEMA_DIALECT.DRAFT_07:
return compileWithAjv(draft07Ajv, schema);
case JSON_SCHEMA_DIALECT.DRAFT_2019_09:
return compileWithAjv(draft2019Ajv, schema);
case JSON_SCHEMA_DIALECT.DRAFT_2020_12:
return compileWithAjv(draft2020Ajv, schema);
}
}

export function formatMcpJsonSchemaValidationErrors(
errors: ErrorObject[] | null | undefined,
): string {
// Ajv's error representation and formatter are shared across dialects.
return draft2020Ajv.errorsText(errors);
}

function compileWithAjv(
ajv: Ajv | Ajv2019 | Ajv2020,
schema: unknown,
): ValidateFunction {
// addUsedSchema: false prevents remote root $id values from entering Ajv's
// shared schema registry or conflicting across MCP servers.
return ajv.compile(schema as AnySchema);
}

function rejectAsyncSchema(schema: unknown): void {
if (
typeof schema === 'object'
&& schema !== null
&& (schema as Record<string, unknown>).$async === true
) {
// $async is an Ajv extension rather than a JSON Schema keyword. The
// surrounding AI SDK validation contract is synchronous, so accepting
// it would treat a returned Promise as a successful validation.
throw new UnsupportedMcpJsonSchemaFeatureError();
}
}

function getJsonSchemaDialect(schema: unknown): JsonSchemaDialect {
if (typeof schema !== 'object' || schema === null || !Object.prototype.hasOwnProperty.call(schema, '$schema')) {
return JSON_SCHEMA_DIALECT.DRAFT_2020_12;
}

const declaredDialect = (schema as Record<string, unknown>).$schema;
if (typeof declaredDialect !== 'string') {
throw new UnsupportedMcpJsonSchemaDialectError();
}

// An empty URI fragment does not change the selected dialect. Normalizing
// it also accepts both forms used by draft-07 schemas in the wild.
const normalizedDialect = declaredDialect.endsWith('#')
? declaredDialect.slice(0, -1)
: declaredDialect;
const dialect = DIALECT_BY_META_SCHEMA_URI.get(normalizedDialect);
if (!dialect) {
throw new UnsupportedMcpJsonSchemaDialectError();
}

return dialect;
}
Loading
Loading