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
5 changes: 5 additions & 0 deletions .changeset/green-carpets-trade.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@contextvm/sdk': minor
---

Add CEP-15 common schema support for `tools/list`, including schema hash metadata for compatible tools and `i`/`k` discovery tags in public announcement events.
79 changes: 43 additions & 36 deletions bun.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@
"@modelcontextprotocol/sdk": "^1.29.0",
"@noble/hashes": "^2.2.0",
"applesauce-relay": "^5.2.0",
"canonicalize": "^2.1.0",
"nostr-tools": "~2.18.2",
"pino": "^10.3.1",
"rxjs": "^7.8.2",
Expand Down
6 changes: 6 additions & 0 deletions src/core/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,3 +134,9 @@ export const announcementMethods: AnnouncementMethods = {

export const INITIALIZE_METHOD = 'initialize';
export const NOTIFICATIONS_INITIALIZED_METHOD = 'notifications/initialized';

/**
* Namespace for CEP-15 common schema metadata in tool definitions.
*/
export const COMMON_SCHEMA_META_NAMESPACE = 'io.contextvm/common-schema';

1 change: 1 addition & 0 deletions src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ export * from './constants.js';
export * from './interfaces.js';
export * from './utils/websocket.js';
export * from './utils/serializers.js';
export * from './utils/common-schema.js';
export * from './encryption.js';
274 changes: 274 additions & 0 deletions src/core/utils/common-schema.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,274 @@
import { describe, expect, test } from 'bun:test';
import {
computeCommonSchemaHash,
normalizeSchema,
} from './common-schema.js';

describe('normalizeSchema', () => {
test('recursively removes title and description fields', () => {
const schema = {
title: 'Top Level',
description: 'Top description',
type: 'object',
properties: {
city: {
type: 'string',
title: 'City',
description: 'City name',
},
nested: {
type: 'object',
description: 'Nested object',
properties: {
value: {
type: 'number',
title: 'Value',
},
},
},
},
anyOf: [
{
type: 'string',
description: 'Variant A',
},
{
type: 'number',
title: 'Variant B',
},
],
};

const normalized: unknown = normalizeSchema(schema);

expect(normalized).toEqual({
type: 'object',
properties: {
city: {
type: 'string',
},
nested: {
type: 'object',
properties: {
value: {
type: 'number',
},
},
},
},
anyOf: [
{
type: 'string',
},
{
type: 'number',
},
],
});
});

test('recursively removes all documentation and vendor-extension fields', () => {
const schema = {
title: 'Top Level',
description: 'Top description',
default: 'foo',
examples: ['foo', 'bar'],
deprecated: true,
readOnly: false,
writeOnly: true,
'x-custom-meta': 'some value',
type: 'object',
properties: {
city: {
type: 'string',
title: 'City',
'x-internal-id': 123,
default: 'New York',
},
},
};

const normalized: unknown = normalizeSchema(schema);

expect(normalized).toEqual({
type: 'object',
properties: {
city: {
type: 'string',
},
},
});
});

test('throws an error if an external $ref is encountered', () => {
const schema = {
type: 'object',
properties: {
location: {
$ref: 'http://example.com/schema.json',
},
},
};

expect(() => normalizeSchema(schema)).toThrow(
'External $ref pointers must be resolved before computing common schema hash'
);
});

test('preserves local $ref pointers', () => {
const schema = {
type: 'object',
properties: {
location: {
$ref: '#/definitions/Location',
},
},
};

const normalized: unknown = normalizeSchema(schema);

expect(normalized).toEqual(schema);
});
});

describe('computeCommonSchemaHash', () => {
test('produces the same hash when only documentation text changes', () => {
const first = computeCommonSchemaHash({
name: 'translate_text',
inputSchema: {
type: 'object',
properties: {
text: {
type: 'string',
description: 'Text to translate',
},
},
required: ['text'],
},
outputSchema: {
type: 'object',
properties: {
translated_text: {
type: 'string',
title: 'Translated text',
},
},
required: ['translated_text'],
},
});

const second = computeCommonSchemaHash({
name: 'translate_text',
inputSchema: {
title: 'Translate input',
type: 'object',
properties: {
text: {
type: 'string',
description: 'User content',
},
},
required: ['text'],
},
outputSchema: {
type: 'object',
description: 'Translate output',
properties: {
translated_text: {
type: 'string',
title: 'Output',
},
},
required: ['translated_text'],
},
});

expect(first).toBe(second);
});

test('changes when schema structure changes', () => {
const first = computeCommonSchemaHash({
name: 'get_weather',
inputSchema: {
type: 'object',
properties: {
location: { type: 'string' },
},
required: ['location'],
},
});

const second = computeCommonSchemaHash({
name: 'get_weather',
inputSchema: {
type: 'object',
properties: {
location: { type: 'string' },
units: { type: 'string' },
},
required: ['location'],
},
});

expect(first).not.toBe(second);
});

test('changes when outputSchema presence changes', () => {
const withoutOutput = computeCommonSchemaHash({
name: 'get_weather',
inputSchema: {
type: 'object',
properties: {
location: { type: 'string' },
},
required: ['location'],
},
});

const withOutput = computeCommonSchemaHash({
name: 'get_weather',
inputSchema: {
type: 'object',
properties: {
location: { type: 'string' },
},
required: ['location'],
},
outputSchema: {
type: 'object',
properties: {
temperature: { type: 'number' },
},
required: ['temperature'],
},
});

expect(withoutOutput).not.toBe(withOutput);
});

test('changes when tool name changes', () => {
const first = computeCommonSchemaHash({
name: 'translate_text',
inputSchema: {
type: 'object',
properties: {
text: { type: 'string' },
},
required: ['text'],
},
});

const second = computeCommonSchemaHash({
name: 'translate_message',
inputSchema: {
type: 'object',
properties: {
text: { type: 'string' },
},
required: ['text'],
},
});

expect(first).not.toBe(second);
});
});
94 changes: 94 additions & 0 deletions src/core/utils/common-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import canonicalizePackage from 'canonicalize';
import type { Tool } from '@modelcontextprotocol/sdk/types.js';
import { sha256 } from '@noble/hashes/sha2.js';
import { bytesToHex } from '@noble/hashes/utils.js';

export interface CommonToolSchemaDefinition {
name: Tool['name'];
inputSchema: Tool['inputSchema'];
outputSchema?: Tool['outputSchema'];
}

type CanonicalizeFn = (input: unknown) => string | undefined;
const canonicalize = canonicalizePackage as unknown as CanonicalizeFn;

function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

/**
* Recursively removes documentation-only JSON Schema fields used by CEP-15.
*
* The normalization rule explicitly strips non-functional fields such as `title`,
* `description`, `examples`, `default`, `deprecated`, `readOnly`, `writeOnly`,
* and any vendor extensions (`x-*`) while preserving compatibility-relevant structure.
*
* @param schema The JSON Schema value to normalize.
* @returns A normalized copy of the schema.
*/
export function normalizeSchema<T>(schema: T): T {
if (Array.isArray(schema)) {
return schema.map((item) => normalizeSchema(item)) as T;
}

if (!isPlainObject(schema)) {
return schema;
}

const normalized: Record<string, unknown> = {};

Object.keys(schema).forEach((key) => {
if (
key === 'title' ||
key === 'description' ||
key === 'default' ||
key === 'examples' ||
key === 'deprecated' ||
key === 'readOnly' ||
key === 'writeOnly' ||
key.startsWith('x-')
) {
return;
}

if (
key === '$ref' &&
typeof schema[key] === 'string' &&
!(schema[key] as string).startsWith('#')
) {
throw new Error(
'External $ref pointers must be resolved before computing common schema hash',
);
}

normalized[key] = normalizeSchema(schema[key]);
});

return normalized as T;
}

/**
* Computes the CEP-15 schema hash for a common tool definition.
*
* @param definition Tool name and JSON Schemas participating in compatibility.
* @returns A deterministic SHA-256 hash of the normalized schema payload.
*/
export function computeCommonSchemaHash(
definition: CommonToolSchemaDefinition,
): string {
const payload: CommonToolSchemaDefinition = {
name: definition.name,
inputSchema: normalizeSchema(definition.inputSchema),
};

if (definition.outputSchema != null) {
payload.outputSchema = normalizeSchema(definition.outputSchema);
}

const canonicalPayload = canonicalize(payload);
if (canonicalPayload === undefined) {
throw new Error('Failed to canonicalize common schema payload');
}

return bytesToHex(sha256(new TextEncoder().encode(canonicalPayload)));
}
Loading
Loading