sandoichi 0.4.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/index.mjs +70 -0
- package/package.json +1 -1
- package/src/artifact-cli.mjs +67 -0
- package/src/artifact-recovery.mjs +133 -0
- package/src/artifact-store.mjs +43 -0
- package/src/context-audit-cli.mjs +104 -0
- package/src/context-capture.mjs +200 -0
- package/src/context-classifier.mjs +142 -0
- package/src/context-footprint.mjs +299 -0
- package/src/context-transform.mjs +18 -1
- package/src/core.mjs +18 -2
- package/src/f1-telemetry.mjs +80 -0
- package/src/f4-telemetry.mjs +183 -0
- package/src/gateway-gate-cli.mjs +88 -0
- package/src/gateway-gate.mjs +412 -0
- package/src/history-disclosure.mjs +70 -0
- package/src/lazy-mcp-gateway-stdio.mjs +59 -0
- package/src/lazy-mcp-gateway.mjs +291 -0
- package/src/mcp-server.mjs +31 -5
- package/src/proxy.mjs +424 -15
- package/src/result-disclosure.mjs +109 -0
- package/src/statusline.mjs +8 -19
- package/src/telemetry.mjs +61 -17
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { digestCapability } from './f4-telemetry.mjs';
|
|
2
|
+
|
|
3
|
+
export const LAZY_MCP_GATEWAY_SCHEMA = 'sando-lazy-mcp-gateway/v1';
|
|
4
|
+
export const GATEWAY_CATALOG_TOOL = 'sando_catalog';
|
|
5
|
+
export const GATEWAY_CALL_TOOL = 'sando_call';
|
|
6
|
+
const MAX_CATALOG_RESULTS = 50;
|
|
7
|
+
const GATEWAY_CATALOG_SCHEMA = {
|
|
8
|
+
type: 'object', additionalProperties: false,
|
|
9
|
+
properties: {
|
|
10
|
+
query: { type: 'string' },
|
|
11
|
+
limit: { type: 'integer', minimum: 1, maximum: MAX_CATALOG_RESULTS },
|
|
12
|
+
},
|
|
13
|
+
};
|
|
14
|
+
const GATEWAY_CALL_SCHEMA = {
|
|
15
|
+
type: 'object', additionalProperties: false, required: ['name', 'arguments'],
|
|
16
|
+
properties: {
|
|
17
|
+
name: { type: 'string', pattern: '^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$' },
|
|
18
|
+
arguments: { type: 'object', additionalProperties: true },
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
function unsupportedDownstreamMethod(method) {
|
|
22
|
+
return method === 'sampling/createMessage' || /(?:^|\/)(?:auth|approval|elicitation)(?:\/|$)/.test(method);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function object(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }
|
|
26
|
+
function response(id, result) { return { jsonrpc: '2.0', id, result }; }
|
|
27
|
+
function rpcError(id, code, message, data) { return { jsonrpc: '2.0', id: id ?? null, error: { code, message, ...(data === undefined ? {} : { data }) } }; }
|
|
28
|
+
function tokens(value) { return String(value ?? '').toLowerCase().match(/[a-z0-9]+/g) ?? []; }
|
|
29
|
+
function safeCapabilityDigest(value) {
|
|
30
|
+
try { return typeof value === 'string' && value.length <= 256 ? digestCapability(value) : null; }
|
|
31
|
+
catch { return null; }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const SUPPORTED_SCHEMA_KEYWORDS = new Set([
|
|
35
|
+
'$schema', 'title', 'description', 'default', 'examples', 'deprecated',
|
|
36
|
+
'type', 'const', 'enum', 'oneOf', 'anyOf', 'properties', 'required',
|
|
37
|
+
'additionalProperties', 'items', 'minItems', 'minLength', 'maxLength',
|
|
38
|
+
'pattern', 'minimum', 'maximum',
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
function unsupportedSchemaKeyword(schema) {
|
|
42
|
+
for (const keyword of Object.keys(schema)) if (!SUPPORTED_SCHEMA_KEYWORDS.has(keyword)) return keyword;
|
|
43
|
+
if (schema.type !== undefined && !['object', 'array', 'string', 'integer', 'number', 'boolean'].includes(schema.type)) return `type:${String(schema.type)}`;
|
|
44
|
+
if (Array.isArray(schema.type)) return 'type:array';
|
|
45
|
+
if (schema.additionalProperties !== undefined && typeof schema.additionalProperties !== 'boolean') return 'additionalProperties';
|
|
46
|
+
if (schema.items !== undefined && !object(schema.items)) return 'items';
|
|
47
|
+
if (schema.required !== undefined && !Array.isArray(schema.required)) return 'required';
|
|
48
|
+
if (schema.properties !== undefined && !object(schema.properties)) return 'properties';
|
|
49
|
+
if (schema.oneOf !== undefined && !Array.isArray(schema.oneOf)) return 'oneOf';
|
|
50
|
+
if (schema.anyOf !== undefined && !Array.isArray(schema.anyOf)) return 'anyOf';
|
|
51
|
+
for (const child of Object.values(schema.properties ?? {})) {
|
|
52
|
+
const keyword = object(child) ? unsupportedSchemaKeyword(child) : 'property-schema';
|
|
53
|
+
if (keyword) return keyword;
|
|
54
|
+
}
|
|
55
|
+
for (const child of [...(schema.oneOf ?? []), ...(schema.anyOf ?? [])]) {
|
|
56
|
+
const keyword = object(child) ? unsupportedSchemaKeyword(child) : 'combinator-schema';
|
|
57
|
+
if (keyword) return keyword;
|
|
58
|
+
}
|
|
59
|
+
if (object(schema.items)) return unsupportedSchemaKeyword(schema.items);
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function schemaError(schema, value, path = '$') {
|
|
64
|
+
if (!object(schema)) return `${path} uses unsupported schema shape`;
|
|
65
|
+
const unsupported = unsupportedSchemaKeyword(schema);
|
|
66
|
+
if (unsupported) return `${path} uses unsupported schema keyword ${unsupported}`;
|
|
67
|
+
if (schema.const !== undefined && JSON.stringify(value) !== JSON.stringify(schema.const)) return `${path} must equal const`;
|
|
68
|
+
if (Array.isArray(schema.enum) && !schema.enum.some((candidate) => JSON.stringify(candidate) === JSON.stringify(value))) return `${path} must be one of enum values`;
|
|
69
|
+
if (schema.oneOf && !schema.oneOf.some((candidate) => !schemaError(candidate, value, path))) return `${path} does not match oneOf`;
|
|
70
|
+
if (schema.anyOf && !schema.anyOf.some((candidate) => !schemaError(candidate, value, path))) return `${path} does not match anyOf`;
|
|
71
|
+
if (schema.type === 'object') {
|
|
72
|
+
if (!object(value)) return `${path} must be an object`;
|
|
73
|
+
for (const name of schema.required ?? []) if (!(name in value)) return `${path}.${name} is required`;
|
|
74
|
+
if (schema.additionalProperties === false) for (const name of Object.keys(value)) if (!schema.properties?.[name]) return `${path}.${name} is not allowed`;
|
|
75
|
+
for (const [name, child] of Object.entries(schema.properties ?? {})) if (name in value) { const error = schemaError(child, value[name], `${path}.${name}`); if (error) return error; }
|
|
76
|
+
} else if (schema.type === 'array') {
|
|
77
|
+
if (!Array.isArray(value)) return `${path} must be an array`;
|
|
78
|
+
if (schema.minItems !== undefined && value.length < schema.minItems) return `${path} has too few items`;
|
|
79
|
+
for (let i = 0; i < value.length; i += 1) { const error = schemaError(schema.items, value[i], `${path}[${i}]`); if (error) return error; }
|
|
80
|
+
} else if (schema.type === 'string') {
|
|
81
|
+
if (typeof value !== 'string') return `${path} must be a string`;
|
|
82
|
+
if (schema.minLength !== undefined && value.length < schema.minLength) return `${path} is too short`;
|
|
83
|
+
if (schema.maxLength !== undefined && value.length > schema.maxLength) return `${path} is too long`;
|
|
84
|
+
if (schema.pattern && !(new RegExp(schema.pattern).test(value))) return `${path} has an invalid format`;
|
|
85
|
+
} else if (schema.type === 'integer') {
|
|
86
|
+
if (!Number.isSafeInteger(value)) return `${path} must be an integer`;
|
|
87
|
+
if (schema.minimum !== undefined && value < schema.minimum) return `${path} is below minimum`;
|
|
88
|
+
if (schema.maximum !== undefined && value > schema.maximum) return `${path} is above maximum`;
|
|
89
|
+
} else if (schema.type === 'number') {
|
|
90
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) return `${path} must be a number`;
|
|
91
|
+
if (schema.minimum !== undefined && value < schema.minimum) return `${path} is below minimum`;
|
|
92
|
+
if (schema.maximum !== undefined && value > schema.maximum) return `${path} is above maximum`;
|
|
93
|
+
} else if (schema.type === 'boolean' && typeof value !== 'boolean') return `${path} must be a boolean`;
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function validateJsonSchema(schema, value) {
|
|
98
|
+
const message = schemaError(schema, value);
|
|
99
|
+
return message ? { valid: false, message } : { valid: true };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function validateConfig(config) {
|
|
103
|
+
if (!object(config) || typeof config.enabled !== 'boolean' || !Array.isArray(config.allowlist) || !Array.isArray(config.servers)) throw new TypeError('gateway requires enabled, allowlist, and servers');
|
|
104
|
+
if (new Set(config.allowlist).size !== config.allowlist.length) throw new TypeError('gateway allowlist contains duplicate names');
|
|
105
|
+
const allowlist = new Set(config.allowlist);
|
|
106
|
+
if ([...allowlist].some((name) => typeof name !== 'string' || !/^[A-Za-z0-9_.-]+$/.test(name))) throw new TypeError('gateway allowlist contains an invalid server');
|
|
107
|
+
const servers = new Map();
|
|
108
|
+
for (const server of config.servers) {
|
|
109
|
+
if (!object(server) || typeof server.name !== 'string' || typeof server.connect !== 'function') throw new TypeError('gateway server requires name and connect');
|
|
110
|
+
if (servers.has(server.name)) throw new TypeError('gateway servers contain duplicate names');
|
|
111
|
+
if (allowlist.has(server.name)) servers.set(server.name, server);
|
|
112
|
+
}
|
|
113
|
+
if ([...allowlist].some((name) => !servers.has(name))) throw new TypeError('gateway allowlist references an unconfigured server');
|
|
114
|
+
const timeoutMs = config.timeoutMs ?? 30_000;
|
|
115
|
+
if (typeof timeoutMs !== 'number' || !Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new TypeError('gateway timeoutMs must be a positive finite number');
|
|
116
|
+
return { ...config, allowlist, servers, timeoutMs };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function createLazyMcpGateway(config) {
|
|
120
|
+
const options = validateConfig(config);
|
|
121
|
+
const connections = new Map();
|
|
122
|
+
const tools = new Map();
|
|
123
|
+
const invalidated = new Set();
|
|
124
|
+
const pending = new Map();
|
|
125
|
+
const connectionLocks = new Map();
|
|
126
|
+
const onMessage = options.onMessage ?? (() => {});
|
|
127
|
+
const onF4Event = options.onF4Event ?? (() => {});
|
|
128
|
+
|
|
129
|
+
function emitF4Event({ operation, outcome, startedAt, resultCount = null, capabilityDigest = null }) {
|
|
130
|
+
try {
|
|
131
|
+
onF4Event({
|
|
132
|
+
operation,
|
|
133
|
+
outcome,
|
|
134
|
+
latencyMs: Math.max(0, Date.now() - startedAt),
|
|
135
|
+
resultCount,
|
|
136
|
+
capabilityDigest,
|
|
137
|
+
});
|
|
138
|
+
} catch { /* tracing must never affect MCP behavior */ }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function forward(message) {
|
|
142
|
+
if (message?.method && unsupportedDownstreamMethod(message.method)) {
|
|
143
|
+
if (message.id !== undefined) return rpcError(message.id, -32003, 'Unsupported downstream request; gateway fails closed', { method: message.method });
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
if (message?.method && message.id === undefined) { onMessage(message); return null; }
|
|
147
|
+
if (message?.method && message.id !== undefined) return rpcError(message.id, -32003, 'Unsupported downstream request; gateway fails closed', { method: message.method });
|
|
148
|
+
onMessage(message);
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
async function connection(name) {
|
|
152
|
+
if (connections.has(name)) return connections.get(name);
|
|
153
|
+
if (connectionLocks.has(name)) return connectionLocks.get(name);
|
|
154
|
+
const lock = connect(name);
|
|
155
|
+
connectionLocks.set(name, lock);
|
|
156
|
+
try { return await lock; } finally { if (connectionLocks.get(name) === lock) connectionLocks.delete(name); }
|
|
157
|
+
}
|
|
158
|
+
async function connect(name) {
|
|
159
|
+
const server = options.servers.get(name);
|
|
160
|
+
const transport = await server.connect({ onMessage: (message) => {
|
|
161
|
+
if (message?.method === 'notifications/tools/list_changed') { for (const key of tools.keys()) if (key.startsWith(`${name}/`)) tools.delete(key); invalidated.add(name); }
|
|
162
|
+
return forward(message);
|
|
163
|
+
} });
|
|
164
|
+
if (!transport || typeof transport.request !== 'function') throw new TypeError(`gateway server ${name} returned an invalid transport`);
|
|
165
|
+
const init = await transport.request({ jsonrpc: '2.0', id: `init:${name}`, method: 'initialize', params: { protocolVersion: '2025-06-18', capabilities: { tools: { listChanged: true } }, clientInfo: { name: 'sando-lazy-mcp-gateway', version: '1' } } }, { notify: forward });
|
|
166
|
+
if (init?.error) throw Object.assign(new Error(init.error.message || 'downstream initialize failed'), init.error);
|
|
167
|
+
const item = { server, transport };
|
|
168
|
+
try {
|
|
169
|
+
await loadTools(name, item);
|
|
170
|
+
connections.set(name, item);
|
|
171
|
+
return item;
|
|
172
|
+
} catch (error) {
|
|
173
|
+
await transport.close?.();
|
|
174
|
+
throw error;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
async function loadTools(name, item) {
|
|
178
|
+
const result = await item.transport.request({ jsonrpc: '2.0', id: `list:${name}:${Date.now()}`, method: 'tools/list', params: {} }, { notify: forward });
|
|
179
|
+
if (result?.error) throw Object.assign(new Error(result.error.message || 'downstream tools/list failed'), result.error);
|
|
180
|
+
for (const descriptor of result?.result?.tools ?? []) {
|
|
181
|
+
if (!object(descriptor) || typeof descriptor.name !== 'string' || !object(descriptor.inputSchema)) continue;
|
|
182
|
+
tools.set(`${name}/${descriptor.name}`, { ...descriptor, server: name, capability: descriptor.name });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
async function catalog(query = '', limit = MAX_CATALOG_RESULTS) {
|
|
186
|
+
const startedAt = Date.now();
|
|
187
|
+
if (!options.enabled) {
|
|
188
|
+
emitF4Event({ operation: 'catalog', outcome: 'rejected', startedAt });
|
|
189
|
+
return [];
|
|
190
|
+
}
|
|
191
|
+
try {
|
|
192
|
+
const queryTokens = tokens(query);
|
|
193
|
+
const records = [];
|
|
194
|
+
for (const name of options.allowlist) {
|
|
195
|
+
const item = await connection(name);
|
|
196
|
+
if (invalidated.delete(name)) await loadTools(name, item);
|
|
197
|
+
for (const [qualified, descriptor] of tools) if (descriptor.server === name) {
|
|
198
|
+
const text = tokens(`${qualified} ${descriptor.description ?? ''} ${name} ${(options.servers.get(name).capabilities ?? []).join(' ')}`);
|
|
199
|
+
const score = queryTokens.reduce((total, token) => total + (text.includes(token) ? 1 : 0), 0);
|
|
200
|
+
if (!queryTokens.length || score) records.push({ namespace: name, description: String(descriptor.description ?? '').slice(0, 160), server: name, capability: descriptor.capability, name: qualified, score });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const result = records.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name)).slice(0, Math.min(limit, MAX_CATALOG_RESULTS)).map(({ score, ...record }) => record);
|
|
204
|
+
emitF4Event({ operation: 'catalog', outcome: 'success', startedAt, resultCount: result.length });
|
|
205
|
+
return result;
|
|
206
|
+
} catch (error) {
|
|
207
|
+
emitF4Event({ operation: 'catalog', outcome: 'error', startedAt });
|
|
208
|
+
throw error;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
async function call(message) {
|
|
212
|
+
const startedAt = Date.now();
|
|
213
|
+
const qualified = message.params?.name;
|
|
214
|
+
const capabilityDigest = safeCapabilityDigest(qualified);
|
|
215
|
+
const serverName = typeof qualified === 'string' ? qualified.split('/')[0] : '';
|
|
216
|
+
const controller = new AbortController();
|
|
217
|
+
let timer;
|
|
218
|
+
const finish = (result, outcome) => {
|
|
219
|
+
emitF4Event({ operation: 'call', outcome, startedAt, capabilityDigest });
|
|
220
|
+
return result;
|
|
221
|
+
};
|
|
222
|
+
try {
|
|
223
|
+
if (!options.allowlist.has(serverName)) return finish(rpcError(message.id, -32602, 'Unknown or undiscovered tool'), 'rejected');
|
|
224
|
+
await connection(serverName);
|
|
225
|
+
const descriptor = tools.get(qualified);
|
|
226
|
+
if (!descriptor) return finish(rpcError(message.id, -32602, 'Unknown or undiscovered tool'), 'rejected');
|
|
227
|
+
const validation = validateJsonSchema(descriptor.inputSchema, message.params?.arguments ?? {});
|
|
228
|
+
if (!validation.valid) return finish(rpcError(message.id, -32602, `Invalid tool arguments: ${validation.message}`), 'rejected');
|
|
229
|
+
const item = await connection(descriptor.server);
|
|
230
|
+
pending.set(message.id, { controller, item });
|
|
231
|
+
timer = setTimeout(() => {
|
|
232
|
+
controller.abort(Object.assign(new Error('gateway request timeout'), { code: -32001 }));
|
|
233
|
+
item.transport.notify?.({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: message.id, reason: 'timeout' } });
|
|
234
|
+
}, options.timeoutMs);
|
|
235
|
+
const result = await item.transport.request({ ...message, params: { ...message.params, name: descriptor.capability } }, { signal: controller.signal, notify: forward });
|
|
236
|
+
if (result?.error) return finish(rpcError(message.id, result.error.code ?? -32000, result.error.message ?? 'Downstream MCP error', result.error.data), 'error');
|
|
237
|
+
return finish(response(message.id, result?.result ?? result), 'success');
|
|
238
|
+
} catch (error) {
|
|
239
|
+
const code = controller.signal.aborted ? -32001 : (error.code === 'CANCELLED' ? -32800 : (Number.isInteger(error.code) ? error.code : -32000));
|
|
240
|
+
const outcome = controller.signal.aborted
|
|
241
|
+
? (controller.signal.reason?.code === -32001 ? 'timeout' : 'cancelled')
|
|
242
|
+
: error.code === 'CANCELLED' ? 'cancelled' : 'error';
|
|
243
|
+
return finish(rpcError(message.id, code, controller.signal.aborted ? 'Gateway request timed out or was cancelled' : error.message || 'Downstream MCP request failed', error.data), outcome);
|
|
244
|
+
} finally { clearTimeout(timer); pending.delete(message.id); }
|
|
245
|
+
}
|
|
246
|
+
async function handle(message) {
|
|
247
|
+
if (!options.enabled) return rpcError(message?.id, -32004, 'Lazy MCP gateway is disabled');
|
|
248
|
+
if (!object(message) || message.jsonrpc !== '2.0' || typeof message.method !== 'string') return rpcError(message?.id, -32600, 'Invalid Request');
|
|
249
|
+
if (message.method === 'notifications/cancelled') {
|
|
250
|
+
const request = pending.get(message.params?.requestId);
|
|
251
|
+
if (request) { request.controller.abort(new Error('gateway request cancelled')); await request.item.transport.notify?.(message); }
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
if (message.id === undefined) { if (message.method === 'notifications/initialized') return null; return rpcError(null, -32601, 'Method not found'); }
|
|
255
|
+
if (message.method === 'initialize') return response(message.id, { protocolVersion: '2025-06-18', capabilities: { tools: { listChanged: true } }, serverInfo: { name: 'sando-lazy-mcp-gateway', version: '1' } });
|
|
256
|
+
if (message.method === 'ping') return response(message.id, {});
|
|
257
|
+
if (message.method === 'sando/catalog') {
|
|
258
|
+
const arguments_ = message.params ?? {};
|
|
259
|
+
const validation = validateJsonSchema(GATEWAY_CATALOG_SCHEMA, arguments_);
|
|
260
|
+
if (!validation.valid) {
|
|
261
|
+
emitF4Event({ operation: 'catalog', outcome: 'rejected', startedAt: Date.now() });
|
|
262
|
+
return rpcError(message.id, -32602, `Invalid catalog arguments: ${validation.message}`);
|
|
263
|
+
}
|
|
264
|
+
return response(message.id, { schema: LAZY_MCP_GATEWAY_SCHEMA, entries: await catalog(arguments_.query, arguments_.limit) });
|
|
265
|
+
}
|
|
266
|
+
if (message.method === 'tools/list') return response(message.id, { tools: [
|
|
267
|
+
{ name: GATEWAY_CATALOG_TOOL, description: 'Search the explicit allowlisted MCP catalog.', inputSchema: GATEWAY_CATALOG_SCHEMA },
|
|
268
|
+
{ name: GATEWAY_CALL_TOOL, description: 'Call one exact qualified name returned by sando_catalog.', inputSchema: GATEWAY_CALL_SCHEMA },
|
|
269
|
+
] });
|
|
270
|
+
if (message.method === 'tools/call' && message.params?.name === GATEWAY_CATALOG_TOOL) {
|
|
271
|
+
const arguments_ = message.params.arguments === undefined ? {} : message.params.arguments;
|
|
272
|
+
const validation = validateJsonSchema(GATEWAY_CATALOG_SCHEMA, arguments_);
|
|
273
|
+
if (!validation.valid) {
|
|
274
|
+
emitF4Event({ operation: 'catalog', outcome: 'rejected', startedAt: Date.now() });
|
|
275
|
+
return rpcError(message.id, -32602, `Invalid catalog arguments: ${validation.message}`);
|
|
276
|
+
}
|
|
277
|
+
return response(message.id, { content: [{ type: 'text', text: JSON.stringify(await catalog(arguments_.query, arguments_.limit)) }] });
|
|
278
|
+
}
|
|
279
|
+
if (message.method === 'tools/call' && message.params?.name === GATEWAY_CALL_TOOL) {
|
|
280
|
+
const validation = validateJsonSchema(GATEWAY_CALL_SCHEMA, message.params?.arguments);
|
|
281
|
+
if (!validation.valid) {
|
|
282
|
+
emitF4Event({ operation: 'call', outcome: 'rejected', startedAt: Date.now(), capabilityDigest: safeCapabilityDigest(message.params?.arguments?.name) });
|
|
283
|
+
return rpcError(message.id, -32602, `Invalid tool arguments: ${validation.message}`);
|
|
284
|
+
}
|
|
285
|
+
return call({ ...message, params: { ...message.params, name: message.params.arguments.name, arguments: message.params.arguments.arguments } });
|
|
286
|
+
}
|
|
287
|
+
if (message.method === 'tools/call') return call(message);
|
|
288
|
+
return rpcError(message.id, -32601, 'Method not found');
|
|
289
|
+
}
|
|
290
|
+
return Object.freeze({ handle, catalog, validateJsonSchema, close: async () => { for (const { transport } of connections.values()) await transport.close?.(); connections.clear(); tools.clear(); } });
|
|
291
|
+
}
|
package/src/mcp-server.mjs
CHANGED
|
@@ -1,17 +1,38 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import path from 'node:path';
|
|
1
4
|
import readline from 'node:readline';
|
|
5
|
+
import { pathToFileURL } from 'node:url';
|
|
2
6
|
|
|
7
|
+
import { exposeMcpResult, recoverStoredArtifact } from './artifact-store.mjs';
|
|
3
8
|
import { optimizeToolOutput } from './core.mjs';
|
|
9
|
+
import { ARTIFACT_TOOL_NAME } from './result-disclosure.mjs';
|
|
4
10
|
import { PLUGIN_VERSION } from './version.mjs';
|
|
5
11
|
|
|
6
12
|
const TOOL = {
|
|
7
13
|
name: 'prepare_tool_output',
|
|
8
|
-
description: 'Prepare deterministic bounded inline output and
|
|
14
|
+
description: 'Prepare deterministic bounded inline output and optional redacted artifact handle. Full content is recovered with sando_artifact_get.',
|
|
9
15
|
inputSchema: {
|
|
10
16
|
type: 'object', additionalProperties: false, required: ['toolName', 'output', 'cwd'],
|
|
11
17
|
properties: { toolName: { type: 'string', minLength: 1, maxLength: 128 }, output: {}, cwd: { type: 'string', minLength: 1 }, policy: { type: 'object' } },
|
|
12
18
|
},
|
|
13
19
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
14
20
|
};
|
|
21
|
+
const ARTIFACT_TOOL = {
|
|
22
|
+
name: ARTIFACT_TOOL_NAME,
|
|
23
|
+
description: 'Recover a bounded redacted byte or line range from an artifact created in this MCP session.',
|
|
24
|
+
inputSchema: {
|
|
25
|
+
type: 'object', additionalProperties: false, required: ['ref'],
|
|
26
|
+
properties: {
|
|
27
|
+
ref: { type: 'string', pattern: '^sando:sha256:[a-f0-9]{16,64}$' },
|
|
28
|
+
startByte: { type: 'integer', minimum: 0 }, endByte: { type: 'integer', minimum: 0 },
|
|
29
|
+
startLine: { type: 'integer', minimum: 1 }, endLine: { type: 'integer', minimum: 1 },
|
|
30
|
+
maxBytes: { type: 'integer', minimum: 1, maximum: 1048576 },
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
34
|
+
};
|
|
35
|
+
const TOOLS = [TOOL, ARTIFACT_TOOL];
|
|
15
36
|
|
|
16
37
|
function response(id, result) { return { jsonrpc: '2.0', id, result }; }
|
|
17
38
|
function error(id, code, message) { return { jsonrpc: '2.0', id: id ?? null, error: { code, message } }; }
|
|
@@ -23,12 +44,15 @@ function dispatch(message) {
|
|
|
23
44
|
protocolVersion: message.params?.protocolVersion || '2025-06-18', capabilities: { tools: { listChanged: false } }, serverInfo: { name: 'sando', version: PLUGIN_VERSION },
|
|
24
45
|
});
|
|
25
46
|
if (message.method === 'ping') return response(message.id, {});
|
|
26
|
-
if (message.method === 'tools/list') return response(message.id, { tools:
|
|
47
|
+
if (message.method === 'tools/list') return response(message.id, { tools: TOOLS });
|
|
27
48
|
if (message.method === 'tools/call') {
|
|
28
|
-
if (
|
|
49
|
+
if (!TOOLS.some((tool) => tool.name === message.params?.name)) return error(message.id, -32602, 'Unknown tool');
|
|
29
50
|
try {
|
|
30
|
-
const result =
|
|
31
|
-
|
|
51
|
+
const result = message.params.name === TOOL.name
|
|
52
|
+
? optimizeToolOutput(message.params.arguments)
|
|
53
|
+
: recoverStoredArtifact(message.params.arguments);
|
|
54
|
+
const exposed = message.params.name === TOOL.name ? exposeMcpResult(result) : result;
|
|
55
|
+
return response(message.id, { content: [{ type: 'text', text: exposed.inline ?? exposed.content }], structuredContent: exposed, isError: false });
|
|
32
56
|
} catch (cause) {
|
|
33
57
|
return response(message.id, { content: [{ type: 'text', text: cause instanceof Error ? cause.message : 'invalid tool input' }], isError: true });
|
|
34
58
|
}
|
|
@@ -44,3 +68,5 @@ export function startMcpServer() {
|
|
|
44
68
|
if (output) process.stdout.write(`${JSON.stringify(output)}\n`);
|
|
45
69
|
});
|
|
46
70
|
}
|
|
71
|
+
|
|
72
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) startMcpServer();
|