Skip to content

Commit 179e3fc

Browse files
committed
fix(protocol): make tool contract assertions reviewable
Coverage: 96.01% lines, 91.95% branches
1 parent c80b83d commit 179e3fc

1 file changed

Lines changed: 21 additions & 29 deletions

File tree

__tests__/integration/mcp-handlers.test.ts

Lines changed: 21 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
*/
1010

1111
import { strict as assert } from 'node:assert';
12-
import { createHash } from 'node:crypto';
1312
import { readFileSync } from 'node:fs';
1413
import http from 'node:http';
1514
import net from 'node:net';
@@ -64,28 +63,26 @@ function propertyStructuralSnapshot(properties: Record<string, Record<string, un
6463
}
6564

6665
function toolSnapshot(tool: ToolSnapshotInput) {
66+
const properties = tool.inputSchema.properties ?? {};
6767
return {
6868
name: tool.name,
6969
annotations: tool.annotations,
7070
required: tool.inputSchema.required ?? [],
71-
properties: propertyStructuralSnapshot(tool.inputSchema.properties ?? {}),
71+
propertyOrder: Object.keys(properties),
72+
properties: propertyStructuralSnapshot(properties),
7273
};
7374
}
7475

7576
function toolStructuralSnapshot(tools: ToolSnapshotInput[]) {
7677
return tools.map(toolSnapshot);
7778
}
7879

79-
function structuralDigest(value: unknown): string {
80-
return createHash('sha256').update(JSON.stringify(value)).digest('hex');
81-
}
82-
8380
function wireJson<T>(value: T): T {
8481
return JSON.parse(JSON.stringify(value)) as T;
8582
}
8683

87-
const FROZEN_FULL_TOOL_SNAPSHOTS = [
88-
{ name: 'searxng_web_search', annotations: { readOnlyHint: true, openWorldHint: true }, required: ['query'], properties: {
84+
const FULL_TOOL_STRUCTURAL_SNAPSHOTS = [
85+
{ name: 'searxng_web_search', annotations: { readOnlyHint: true, openWorldHint: true }, required: ['query'], propertyOrder: ['query', 'pageno', 'time_range', 'language', 'safesearch', 'min_score', 'num_results', 'categories', 'engines', 'response_format', 'result_detail'], properties: {
8986
query: { type: 'string', minimum: undefined, maximum: undefined, enum: undefined, default: undefined },
9087
pageno: { type: 'integer', minimum: 1, maximum: undefined, enum: undefined, default: 1 },
9188
time_range: { type: 'string', minimum: undefined, maximum: undefined, enum: ['day', 'week', 'month', 'year'], default: undefined },
@@ -98,31 +95,31 @@ const FROZEN_FULL_TOOL_SNAPSHOTS = [
9895
response_format: { type: 'string', minimum: undefined, maximum: undefined, enum: ['text', 'json'], default: undefined },
9996
result_detail: { type: 'string', minimum: undefined, maximum: undefined, enum: ['compact', 'full'], default: undefined },
10097
} },
101-
{ name: 'web_url_read', annotations: { readOnlyHint: true, openWorldHint: true }, required: ['url'], properties: {
98+
{ name: 'web_url_read', annotations: { readOnlyHint: true, openWorldHint: true }, required: ['url'], propertyOrder: ['url', 'startChar', 'maxLength', 'section', 'paragraphRange', 'readHeadings'], properties: {
10299
url: { type: 'string', minimum: undefined, maximum: undefined, enum: undefined, default: undefined },
103100
startChar: { type: 'number', minimum: 0, maximum: undefined, enum: undefined, default: undefined },
104101
maxLength: { type: 'number', minimum: 1, maximum: undefined, enum: undefined, default: undefined },
105102
section: { type: 'string', minimum: undefined, maximum: undefined, enum: undefined, default: undefined },
106103
paragraphRange: { type: 'string', minimum: undefined, maximum: undefined, enum: undefined, default: undefined },
107104
readHeadings: { type: 'boolean', minimum: undefined, maximum: undefined, enum: undefined, default: undefined },
108105
} },
109-
{ name: 'searxng_search_suggestions', annotations: { readOnlyHint: true, openWorldHint: true }, required: ['query'], properties: {
106+
{ name: 'searxng_search_suggestions', annotations: { readOnlyHint: true, openWorldHint: true }, required: ['query'], propertyOrder: ['query', 'language'], properties: {
110107
query: { type: 'string', minimum: undefined, maximum: undefined, enum: undefined, default: undefined },
111108
language: { type: 'string', minimum: undefined, maximum: undefined, enum: undefined, default: 'all' },
112109
} },
113-
{ name: 'searxng_instance_info', annotations: { readOnlyHint: true, openWorldHint: true }, required: [], properties: {
110+
{ name: 'searxng_instance_info', annotations: { readOnlyHint: true, openWorldHint: true }, required: [], propertyOrder: ['includeEngines', 'includeDisabled', 'category', 'refresh'], properties: {
114111
includeEngines: { type: 'boolean', minimum: undefined, maximum: undefined, enum: undefined, default: false },
115112
includeDisabled: { type: 'boolean', minimum: undefined, maximum: undefined, enum: undefined, default: false },
116113
category: { type: 'string', minimum: undefined, maximum: undefined, enum: undefined, default: undefined },
117114
refresh: { type: 'boolean', minimum: undefined, maximum: undefined, enum: undefined, default: false },
118115
} },
119116
];
120117

121-
const FROZEN_LITE_TOOL_SNAPSHOTS = [
122-
{ name: 'searxng_web_search', annotations: undefined, required: ['query'], properties: { query: { type: 'string', minimum: undefined, maximum: undefined, enum: undefined, default: undefined } } },
123-
{ name: 'web_url_read', annotations: undefined, required: ['url'], properties: { url: { type: 'string', minimum: undefined, maximum: undefined, enum: undefined, default: undefined } } },
124-
{ name: 'searxng_search_suggestions', annotations: undefined, required: ['query'], properties: { query: { type: 'string', minimum: undefined, maximum: undefined, enum: undefined, default: undefined } } },
125-
{ name: 'searxng_instance_info', annotations: undefined, required: [], properties: {} },
118+
const LITE_TOOL_STRUCTURAL_SNAPSHOTS = [
119+
{ name: 'searxng_web_search', annotations: undefined, required: ['query'], propertyOrder: ['query'], properties: { query: { type: 'string', minimum: undefined, maximum: undefined, enum: undefined, default: undefined } } },
120+
{ name: 'web_url_read', annotations: undefined, required: ['url'], propertyOrder: ['url'], properties: { url: { type: 'string', minimum: undefined, maximum: undefined, enum: undefined, default: undefined } } },
121+
{ name: 'searxng_search_suggestions', annotations: undefined, required: ['query'], propertyOrder: ['query'], properties: { query: { type: 'string', minimum: undefined, maximum: undefined, enum: undefined, default: undefined } } },
122+
{ name: 'searxng_instance_info', annotations: undefined, required: [], propertyOrder: [], properties: {} },
126123
];
127124

128125
/** Spin up a fresh Client↔Server pair for each test. Call client.close() when done. */
@@ -594,21 +591,16 @@ async function runTests() {
594591
await client.close();
595592
}, results);
596593

597-
await testFunction('full and lite tool schemas and annotations retain their frozen structural contract', async () => {
594+
await testFunction('full and lite tools retain reviewable source, wire, and structural contracts', async () => {
598595
const fullDefinitions = [WEB_SEARCH_TOOL, SUGGESTIONS_TOOL, INSTANCE_INFO_TOOL, READ_URL_TOOL];
599596
const liteDefinitions = [LITE_WEB_SEARCH_TOOL, LITE_SUGGESTIONS_TOOL, LITE_INSTANCE_INFO_TOOL, LITE_READ_URL_TOOL];
600-
assert.equal(
601-
structuralDigest(fullDefinitions),
602-
'2a5a607d2614632059e9f98e5fa7468374a9086312071f8895f4ba10a3a4618a',
603-
'full tool definitions changed from the frozen pre-migration structure',
604-
);
605-
assert.equal(
606-
structuralDigest(liteDefinitions),
607-
'0d0bb57c16c482517642677de2ec590cda44bff70aead56b6c5110dc6764e782',
608-
'lite tool definitions changed from the frozen pre-migration structure',
609-
);
610-
const fullExpectedInServedOrder = [FROZEN_FULL_TOOL_SNAPSHOTS[0], FROZEN_FULL_TOOL_SNAPSHOTS[2], FROZEN_FULL_TOOL_SNAPSHOTS[3], FROZEN_FULL_TOOL_SNAPSHOTS[1]];
611-
const liteExpectedInServedOrder = [FROZEN_LITE_TOOL_SNAPSHOTS[0], FROZEN_LITE_TOOL_SNAPSHOTS[2], FROZEN_LITE_TOOL_SNAPSHOTS[3], FROZEN_LITE_TOOL_SNAPSHOTS[1]];
597+
const fullExpectedInServedOrder = [FULL_TOOL_STRUCTURAL_SNAPSHOTS[0], FULL_TOOL_STRUCTURAL_SNAPSHOTS[2], FULL_TOOL_STRUCTURAL_SNAPSHOTS[3], FULL_TOOL_STRUCTURAL_SNAPSHOTS[1]];
598+
const liteExpectedInServedOrder = [LITE_TOOL_STRUCTURAL_SNAPSHOTS[0], LITE_TOOL_STRUCTURAL_SNAPSHOTS[2], LITE_TOOL_STRUCTURAL_SNAPSHOTS[3], LITE_TOOL_STRUCTURAL_SNAPSHOTS[1]];
599+
600+
const descriptionsChanged = wireJson(fullDefinitions);
601+
descriptionsChanged[0].description = 'Updated search tool wording';
602+
descriptionsChanged[0].inputSchema.properties!.query.description = 'Updated query wording';
603+
assert.deepEqual(toolStructuralSnapshot(descriptionsChanged), toolStructuralSnapshot(fullDefinitions));
612604

613605
delete process.env.SEARXNG_LITE_TOOLS;
614606
const full = await connect();

0 commit comments

Comments
 (0)