sandoichi 0.4.1 → 0.5.0
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 +6 -2
- package/index.mjs +75 -0
- package/package.json +1 -1
- package/src/accounting-cli.mjs +1 -1
- package/src/artifact-cli.mjs +67 -0
- package/src/artifact-lifecycle.mjs +67 -0
- package/src/artifact-recovery.mjs +138 -0
- package/src/artifact-store.mjs +44 -0
- package/src/cache-attribution.mjs +13 -3
- 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 +146 -17
- package/src/core.mjs +300 -35
- 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-archive.mjs +80 -0
- package/src/history-disclosure.mjs +70 -0
- package/src/hook-cli.mjs +17 -1
- package/src/lazy-mcp-gateway-stdio.mjs +59 -0
- package/src/lazy-mcp-gateway.mjs +295 -0
- package/src/mcp-server.mjs +72 -11
- package/src/metrics.mjs +4 -3
- package/src/provider-usage.mjs +103 -23
- package/src/proxy.mjs +435 -19
- package/src/result-disclosure.mjs +115 -0
- package/src/slice.mjs +419 -0
- package/src/statusline.mjs +5 -9
- package/src/telemetry.mjs +155 -29
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import readline from 'node:readline';
|
|
3
|
+
|
|
4
|
+
export function spawnMcpTransport({ command, args = [], cwd, env, onMessage }) {
|
|
5
|
+
if (typeof command !== 'string' || !command || !Array.isArray(args) || args.some((arg) => typeof arg !== 'string')) throw new TypeError('gateway command configuration is invalid');
|
|
6
|
+
const child = spawn(command, args, { cwd, env: env ? { ...process.env, ...env } : process.env, stdio: ['pipe', 'pipe', 'ignore'] });
|
|
7
|
+
const pending = new Map();
|
|
8
|
+
let sequence = 0;
|
|
9
|
+
let closed = false;
|
|
10
|
+
const lines = readline.createInterface({ input: child.stdout, crlfDelay: Infinity });
|
|
11
|
+
lines.on('line', (line) => {
|
|
12
|
+
let message;
|
|
13
|
+
try { message = JSON.parse(line); } catch { return; }
|
|
14
|
+
if (message.id !== undefined && pending.has(message.id)) { const { resolve } = pending.get(message.id); pending.delete(message.id); resolve(message); }
|
|
15
|
+
else { const reply = onMessage?.(message); if (reply) child.stdin.write(`${JSON.stringify(reply)}\n`); }
|
|
16
|
+
});
|
|
17
|
+
const fail = (error) => { closed = true; for (const { reject } of pending.values()) reject(error); pending.clear(); };
|
|
18
|
+
child.on('error', fail);
|
|
19
|
+
child.on('close', (code) => fail(new Error(`downstream MCP exited with code ${code}`)));
|
|
20
|
+
return {
|
|
21
|
+
request(message, { signal, notify } = {}) {
|
|
22
|
+
if (closed) return Promise.reject(new Error('downstream MCP transport is closed'));
|
|
23
|
+
const id = `sando:${++sequence}`;
|
|
24
|
+
const request = { ...message, id };
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
const abort = () => { pending.delete(id); this.notify({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: id, reason: 'cancelled' } }); reject(Object.assign(new Error('downstream request cancelled'), { code: 'CANCELLED' })); };
|
|
27
|
+
if (signal?.aborted) return abort();
|
|
28
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
29
|
+
pending.set(id, { resolve: (value) => { signal?.removeEventListener('abort', abort); resolve(value); }, reject });
|
|
30
|
+
child.stdin.write(`${JSON.stringify(request)}\n`, (error) => { if (error) reject(error); });
|
|
31
|
+
void notify;
|
|
32
|
+
});
|
|
33
|
+
},
|
|
34
|
+
notify(message) { if (!closed) child.stdin.write(`${JSON.stringify({ ...message, id: undefined })}\n`); },
|
|
35
|
+
close() { if (!closed) { closed = true; child.kill(); lines.close(); } },
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function createConfiguredMcpServers(config) {
|
|
40
|
+
if (!Array.isArray(config?.servers)) throw new TypeError('gateway servers must be an array');
|
|
41
|
+
return config.servers.map((server) => ({ ...server, connect: ({ onMessage }) => spawnMcpTransport({ ...server, onMessage }) }));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function startLazyMcpGatewayStdio({ gateway, input = process.stdin, output = process.stdout } = {}) {
|
|
45
|
+
if (!gateway) throw new TypeError('gateway is required');
|
|
46
|
+
const lines = readline.createInterface({ input, crlfDelay: Infinity });
|
|
47
|
+
let queue = Promise.resolve();
|
|
48
|
+
input.resume();
|
|
49
|
+
lines.on('line', (line) => {
|
|
50
|
+
queue = queue.then(() => processLine(line));
|
|
51
|
+
});
|
|
52
|
+
async function processLine(line) {
|
|
53
|
+
let message;
|
|
54
|
+
try { message = JSON.parse(line); } catch { output.write(`${JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } })}\n`); return; }
|
|
55
|
+
try { const result = await gateway.handle(message); if (result) output.write(`${JSON.stringify(result)}\n`); }
|
|
56
|
+
catch (error) { output.write(`${JSON.stringify({ jsonrpc: '2.0', id: message?.id ?? null, error: { code: error.code ?? -32000, message: error.message || 'Gateway failure' } })}\n`); }
|
|
57
|
+
}
|
|
58
|
+
return lines;
|
|
59
|
+
}
|
|
@@ -0,0 +1,295 @@
|
|
|
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 DESCRIBED_CATALOG_RESULTS = 10;
|
|
8
|
+
const GATEWAY_CATALOG_SCHEMA = {
|
|
9
|
+
type: 'object', additionalProperties: false,
|
|
10
|
+
properties: {
|
|
11
|
+
query: { type: 'string' },
|
|
12
|
+
limit: { type: 'integer', minimum: 1, maximum: MAX_CATALOG_RESULTS },
|
|
13
|
+
describe: { type: 'boolean' },
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
const GATEWAY_CALL_SCHEMA = {
|
|
17
|
+
type: 'object', additionalProperties: false, required: ['name', 'arguments'],
|
|
18
|
+
properties: {
|
|
19
|
+
name: { type: 'string', pattern: '^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$' },
|
|
20
|
+
arguments: { type: 'object', additionalProperties: true },
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
function unsupportedDownstreamMethod(method) {
|
|
24
|
+
return method === 'sampling/createMessage' || /(?:^|\/)(?:auth|approval|elicitation)(?:\/|$)/.test(method);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function object(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }
|
|
28
|
+
function response(id, result) { return { jsonrpc: '2.0', id, result }; }
|
|
29
|
+
function rpcError(id, code, message, data) { return { jsonrpc: '2.0', id: id ?? null, error: { code, message, ...(data === undefined ? {} : { data }) } }; }
|
|
30
|
+
function tokens(value) { return String(value ?? '').toLowerCase().match(/[a-z0-9]+/g) ?? []; }
|
|
31
|
+
function safeCapabilityDigest(value) {
|
|
32
|
+
try { return typeof value === 'string' && value.length <= 256 ? digestCapability(value) : null; }
|
|
33
|
+
catch { return null; }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const SUPPORTED_SCHEMA_KEYWORDS = new Set([
|
|
37
|
+
'$schema', 'title', 'description', 'default', 'examples', 'deprecated',
|
|
38
|
+
'type', 'const', 'enum', 'oneOf', 'anyOf', 'properties', 'required',
|
|
39
|
+
'additionalProperties', 'items', 'minItems', 'minLength', 'maxLength',
|
|
40
|
+
'pattern', 'minimum', 'maximum',
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
function unsupportedSchemaKeyword(schema) {
|
|
44
|
+
for (const keyword of Object.keys(schema)) if (!SUPPORTED_SCHEMA_KEYWORDS.has(keyword)) return keyword;
|
|
45
|
+
if (schema.type !== undefined && !['object', 'array', 'string', 'integer', 'number', 'boolean'].includes(schema.type)) return `type:${String(schema.type)}`;
|
|
46
|
+
if (Array.isArray(schema.type)) return 'type:array';
|
|
47
|
+
if (schema.additionalProperties !== undefined && typeof schema.additionalProperties !== 'boolean') return 'additionalProperties';
|
|
48
|
+
if (schema.items !== undefined && !object(schema.items)) return 'items';
|
|
49
|
+
if (schema.required !== undefined && !Array.isArray(schema.required)) return 'required';
|
|
50
|
+
if (schema.properties !== undefined && !object(schema.properties)) return 'properties';
|
|
51
|
+
if (schema.oneOf !== undefined && !Array.isArray(schema.oneOf)) return 'oneOf';
|
|
52
|
+
if (schema.anyOf !== undefined && !Array.isArray(schema.anyOf)) return 'anyOf';
|
|
53
|
+
for (const child of Object.values(schema.properties ?? {})) {
|
|
54
|
+
const keyword = object(child) ? unsupportedSchemaKeyword(child) : 'property-schema';
|
|
55
|
+
if (keyword) return keyword;
|
|
56
|
+
}
|
|
57
|
+
for (const child of [...(schema.oneOf ?? []), ...(schema.anyOf ?? [])]) {
|
|
58
|
+
const keyword = object(child) ? unsupportedSchemaKeyword(child) : 'combinator-schema';
|
|
59
|
+
if (keyword) return keyword;
|
|
60
|
+
}
|
|
61
|
+
if (object(schema.items)) return unsupportedSchemaKeyword(schema.items);
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function schemaError(schema, value, path = '$') {
|
|
66
|
+
if (!object(schema)) return `${path} uses unsupported schema shape`;
|
|
67
|
+
const unsupported = unsupportedSchemaKeyword(schema);
|
|
68
|
+
if (unsupported) return `${path} uses unsupported schema keyword ${unsupported}`;
|
|
69
|
+
if (schema.const !== undefined && JSON.stringify(value) !== JSON.stringify(schema.const)) return `${path} must equal const`;
|
|
70
|
+
if (Array.isArray(schema.enum) && !schema.enum.some((candidate) => JSON.stringify(candidate) === JSON.stringify(value))) return `${path} must be one of enum values`;
|
|
71
|
+
if (schema.oneOf && !schema.oneOf.some((candidate) => !schemaError(candidate, value, path))) return `${path} does not match oneOf`;
|
|
72
|
+
if (schema.anyOf && !schema.anyOf.some((candidate) => !schemaError(candidate, value, path))) return `${path} does not match anyOf`;
|
|
73
|
+
if (schema.type === 'object') {
|
|
74
|
+
if (!object(value)) return `${path} must be an object`;
|
|
75
|
+
for (const name of schema.required ?? []) if (!(name in value)) return `${path}.${name} is required`;
|
|
76
|
+
if (schema.additionalProperties === false) for (const name of Object.keys(value)) if (!schema.properties?.[name]) return `${path}.${name} is not allowed`;
|
|
77
|
+
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; }
|
|
78
|
+
} else if (schema.type === 'array') {
|
|
79
|
+
if (!Array.isArray(value)) return `${path} must be an array`;
|
|
80
|
+
if (schema.minItems !== undefined && value.length < schema.minItems) return `${path} has too few items`;
|
|
81
|
+
for (let i = 0; i < value.length; i += 1) { const error = schemaError(schema.items, value[i], `${path}[${i}]`); if (error) return error; }
|
|
82
|
+
} else if (schema.type === 'string') {
|
|
83
|
+
if (typeof value !== 'string') return `${path} must be a string`;
|
|
84
|
+
if (schema.minLength !== undefined && value.length < schema.minLength) return `${path} is too short`;
|
|
85
|
+
if (schema.maxLength !== undefined && value.length > schema.maxLength) return `${path} is too long`;
|
|
86
|
+
if (schema.pattern && !(new RegExp(schema.pattern).test(value))) return `${path} has an invalid format`;
|
|
87
|
+
} else if (schema.type === 'integer') {
|
|
88
|
+
if (!Number.isSafeInteger(value)) return `${path} must be an integer`;
|
|
89
|
+
if (schema.minimum !== undefined && value < schema.minimum) return `${path} is below minimum`;
|
|
90
|
+
if (schema.maximum !== undefined && value > schema.maximum) return `${path} is above maximum`;
|
|
91
|
+
} else if (schema.type === 'number') {
|
|
92
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) return `${path} must be a number`;
|
|
93
|
+
if (schema.minimum !== undefined && value < schema.minimum) return `${path} is below minimum`;
|
|
94
|
+
if (schema.maximum !== undefined && value > schema.maximum) return `${path} is above maximum`;
|
|
95
|
+
} else if (schema.type === 'boolean' && typeof value !== 'boolean') return `${path} must be a boolean`;
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function validateJsonSchema(schema, value) {
|
|
100
|
+
const message = schemaError(schema, value);
|
|
101
|
+
return message ? { valid: false, message } : { valid: true };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function validateConfig(config) {
|
|
105
|
+
if (!object(config) || typeof config.enabled !== 'boolean' || !Array.isArray(config.allowlist) || !Array.isArray(config.servers)) throw new TypeError('gateway requires enabled, allowlist, and servers');
|
|
106
|
+
if (new Set(config.allowlist).size !== config.allowlist.length) throw new TypeError('gateway allowlist contains duplicate names');
|
|
107
|
+
const allowlist = new Set(config.allowlist);
|
|
108
|
+
if ([...allowlist].some((name) => typeof name !== 'string' || !/^[A-Za-z0-9_.-]+$/.test(name))) throw new TypeError('gateway allowlist contains an invalid server');
|
|
109
|
+
const servers = new Map();
|
|
110
|
+
for (const server of config.servers) {
|
|
111
|
+
if (!object(server) || typeof server.name !== 'string' || typeof server.connect !== 'function') throw new TypeError('gateway server requires name and connect');
|
|
112
|
+
if (servers.has(server.name)) throw new TypeError('gateway servers contain duplicate names');
|
|
113
|
+
if (allowlist.has(server.name)) servers.set(server.name, server);
|
|
114
|
+
}
|
|
115
|
+
if ([...allowlist].some((name) => !servers.has(name))) throw new TypeError('gateway allowlist references an unconfigured server');
|
|
116
|
+
const timeoutMs = config.timeoutMs ?? 30_000;
|
|
117
|
+
if (typeof timeoutMs !== 'number' || !Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new TypeError('gateway timeoutMs must be a positive finite number');
|
|
118
|
+
return { ...config, allowlist, servers, timeoutMs };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function createLazyMcpGateway(config) {
|
|
122
|
+
const options = validateConfig(config);
|
|
123
|
+
const connections = new Map();
|
|
124
|
+
const tools = new Map();
|
|
125
|
+
const invalidated = new Set();
|
|
126
|
+
const pending = new Map();
|
|
127
|
+
const connectionLocks = new Map();
|
|
128
|
+
const onMessage = options.onMessage ?? (() => {});
|
|
129
|
+
const onF4Event = options.onF4Event ?? (() => {});
|
|
130
|
+
|
|
131
|
+
function emitF4Event({ operation, outcome, startedAt, resultCount = null, capabilityDigest = null }) {
|
|
132
|
+
try {
|
|
133
|
+
onF4Event({
|
|
134
|
+
operation,
|
|
135
|
+
outcome,
|
|
136
|
+
latencyMs: Math.max(0, Date.now() - startedAt),
|
|
137
|
+
resultCount,
|
|
138
|
+
capabilityDigest,
|
|
139
|
+
});
|
|
140
|
+
} catch { /* tracing must never affect MCP behavior */ }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function forward(message) {
|
|
144
|
+
if (message?.method && unsupportedDownstreamMethod(message.method)) {
|
|
145
|
+
if (message.id !== undefined) return rpcError(message.id, -32003, 'Unsupported downstream request; gateway fails closed', { method: message.method });
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
if (message?.method && message.id === undefined) { onMessage(message); return null; }
|
|
149
|
+
if (message?.method && message.id !== undefined) return rpcError(message.id, -32003, 'Unsupported downstream request; gateway fails closed', { method: message.method });
|
|
150
|
+
onMessage(message);
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
async function connection(name) {
|
|
154
|
+
if (connections.has(name)) return connections.get(name);
|
|
155
|
+
if (connectionLocks.has(name)) return connectionLocks.get(name);
|
|
156
|
+
const lock = connect(name);
|
|
157
|
+
connectionLocks.set(name, lock);
|
|
158
|
+
try { return await lock; } finally { if (connectionLocks.get(name) === lock) connectionLocks.delete(name); }
|
|
159
|
+
}
|
|
160
|
+
async function connect(name) {
|
|
161
|
+
const server = options.servers.get(name);
|
|
162
|
+
const transport = await server.connect({ onMessage: (message) => {
|
|
163
|
+
if (message?.method === 'notifications/tools/list_changed') { for (const key of tools.keys()) if (key.startsWith(`${name}/`)) tools.delete(key); invalidated.add(name); }
|
|
164
|
+
return forward(message);
|
|
165
|
+
} });
|
|
166
|
+
if (!transport || typeof transport.request !== 'function') throw new TypeError(`gateway server ${name} returned an invalid transport`);
|
|
167
|
+
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 });
|
|
168
|
+
if (init?.error) throw Object.assign(new Error(init.error.message || 'downstream initialize failed'), init.error);
|
|
169
|
+
const item = { server, transport };
|
|
170
|
+
try {
|
|
171
|
+
await loadTools(name, item);
|
|
172
|
+
connections.set(name, item);
|
|
173
|
+
return item;
|
|
174
|
+
} catch (error) {
|
|
175
|
+
await transport.close?.();
|
|
176
|
+
throw error;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
async function loadTools(name, item) {
|
|
180
|
+
const result = await item.transport.request({ jsonrpc: '2.0', id: `list:${name}:${Date.now()}`, method: 'tools/list', params: {} }, { notify: forward });
|
|
181
|
+
if (result?.error) throw Object.assign(new Error(result.error.message || 'downstream tools/list failed'), result.error);
|
|
182
|
+
for (const descriptor of result?.result?.tools ?? []) {
|
|
183
|
+
if (!object(descriptor) || typeof descriptor.name !== 'string' || !object(descriptor.inputSchema)) continue;
|
|
184
|
+
tools.set(`${name}/${descriptor.name}`, { ...descriptor, server: name, capability: descriptor.name });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
async function catalog(query = '', limit, describe = false) {
|
|
188
|
+
// Schemas are far heavier than names, so describe gets a smaller default.
|
|
189
|
+
const effectiveLimit = limit ?? (describe ? DESCRIBED_CATALOG_RESULTS : MAX_CATALOG_RESULTS);
|
|
190
|
+
const startedAt = Date.now();
|
|
191
|
+
if (!options.enabled) {
|
|
192
|
+
emitF4Event({ operation: 'catalog', outcome: 'rejected', startedAt });
|
|
193
|
+
return [];
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
const queryTokens = tokens(query);
|
|
197
|
+
const records = [];
|
|
198
|
+
for (const name of options.allowlist) {
|
|
199
|
+
const item = await connection(name);
|
|
200
|
+
if (invalidated.delete(name)) await loadTools(name, item);
|
|
201
|
+
for (const [qualified, descriptor] of tools) if (descriptor.server === name) {
|
|
202
|
+
const text = tokens(`${qualified} ${descriptor.description ?? ''} ${name} ${(options.servers.get(name).capabilities ?? []).join(' ')}`);
|
|
203
|
+
const score = queryTokens.reduce((total, token) => total + (text.includes(token) ? 1 : 0), 0);
|
|
204
|
+
if (!queryTokens.length || score) records.push({ namespace: name, description: String(descriptor.description ?? '').slice(0, 160), server: name, capability: descriptor.capability, name: qualified, score, ...(describe && descriptor.inputSchema ? { inputSchema: descriptor.inputSchema } : {}) });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
const result = records.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name)).slice(0, Math.min(effectiveLimit, MAX_CATALOG_RESULTS)).map(({ score, ...record }) => record);
|
|
208
|
+
emitF4Event({ operation: 'catalog', outcome: 'success', startedAt, resultCount: result.length });
|
|
209
|
+
return result;
|
|
210
|
+
} catch (error) {
|
|
211
|
+
emitF4Event({ operation: 'catalog', outcome: 'error', startedAt });
|
|
212
|
+
throw error;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
async function call(message) {
|
|
216
|
+
const startedAt = Date.now();
|
|
217
|
+
const qualified = message.params?.name;
|
|
218
|
+
const capabilityDigest = safeCapabilityDigest(qualified);
|
|
219
|
+
const serverName = typeof qualified === 'string' ? qualified.split('/')[0] : '';
|
|
220
|
+
const controller = new AbortController();
|
|
221
|
+
let timer;
|
|
222
|
+
const finish = (result, outcome) => {
|
|
223
|
+
emitF4Event({ operation: 'call', outcome, startedAt, capabilityDigest });
|
|
224
|
+
return result;
|
|
225
|
+
};
|
|
226
|
+
try {
|
|
227
|
+
if (!options.allowlist.has(serverName)) return finish(rpcError(message.id, -32602, 'Unknown or undiscovered tool'), 'rejected');
|
|
228
|
+
await connection(serverName);
|
|
229
|
+
const descriptor = tools.get(qualified);
|
|
230
|
+
if (!descriptor) return finish(rpcError(message.id, -32602, 'Unknown or undiscovered tool'), 'rejected');
|
|
231
|
+
const validation = validateJsonSchema(descriptor.inputSchema, message.params?.arguments ?? {});
|
|
232
|
+
if (!validation.valid) return finish(rpcError(message.id, -32602, `Invalid tool arguments: ${validation.message}`), 'rejected');
|
|
233
|
+
const item = await connection(descriptor.server);
|
|
234
|
+
pending.set(message.id, { controller, item });
|
|
235
|
+
timer = setTimeout(() => {
|
|
236
|
+
controller.abort(Object.assign(new Error('gateway request timeout'), { code: -32001 }));
|
|
237
|
+
item.transport.notify?.({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: message.id, reason: 'timeout' } });
|
|
238
|
+
}, options.timeoutMs);
|
|
239
|
+
const result = await item.transport.request({ ...message, params: { ...message.params, name: descriptor.capability } }, { signal: controller.signal, notify: forward });
|
|
240
|
+
if (result?.error) return finish(rpcError(message.id, result.error.code ?? -32000, result.error.message ?? 'Downstream MCP error', result.error.data), 'error');
|
|
241
|
+
return finish(response(message.id, result?.result ?? result), 'success');
|
|
242
|
+
} catch (error) {
|
|
243
|
+
const code = controller.signal.aborted ? -32001 : (error.code === 'CANCELLED' ? -32800 : (Number.isInteger(error.code) ? error.code : -32000));
|
|
244
|
+
const outcome = controller.signal.aborted
|
|
245
|
+
? (controller.signal.reason?.code === -32001 ? 'timeout' : 'cancelled')
|
|
246
|
+
: error.code === 'CANCELLED' ? 'cancelled' : 'error';
|
|
247
|
+
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);
|
|
248
|
+
} finally { clearTimeout(timer); pending.delete(message.id); }
|
|
249
|
+
}
|
|
250
|
+
async function handle(message) {
|
|
251
|
+
if (!options.enabled) return rpcError(message?.id, -32004, 'Lazy MCP gateway is disabled');
|
|
252
|
+
if (!object(message) || message.jsonrpc !== '2.0' || typeof message.method !== 'string') return rpcError(message?.id, -32600, 'Invalid Request');
|
|
253
|
+
if (message.method === 'notifications/cancelled') {
|
|
254
|
+
const request = pending.get(message.params?.requestId);
|
|
255
|
+
if (request) { request.controller.abort(new Error('gateway request cancelled')); await request.item.transport.notify?.(message); }
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
if (message.id === undefined) { if (message.method === 'notifications/initialized') return null; return rpcError(null, -32601, 'Method not found'); }
|
|
259
|
+
if (message.method === 'initialize') return response(message.id, { protocolVersion: '2025-06-18', capabilities: { tools: { listChanged: true } }, serverInfo: { name: 'sando-lazy-mcp-gateway', version: '1' } });
|
|
260
|
+
if (message.method === 'ping') return response(message.id, {});
|
|
261
|
+
if (message.method === 'sando/catalog') {
|
|
262
|
+
const arguments_ = message.params ?? {};
|
|
263
|
+
const validation = validateJsonSchema(GATEWAY_CATALOG_SCHEMA, arguments_);
|
|
264
|
+
if (!validation.valid) {
|
|
265
|
+
emitF4Event({ operation: 'catalog', outcome: 'rejected', startedAt: Date.now() });
|
|
266
|
+
return rpcError(message.id, -32602, `Invalid catalog arguments: ${validation.message}`);
|
|
267
|
+
}
|
|
268
|
+
return response(message.id, { schema: LAZY_MCP_GATEWAY_SCHEMA, entries: await catalog(arguments_.query, arguments_.limit, arguments_.describe) });
|
|
269
|
+
}
|
|
270
|
+
if (message.method === 'tools/list') return response(message.id, { tools: [
|
|
271
|
+
{ name: GATEWAY_CATALOG_TOOL, description: 'Search the explicit allowlisted MCP catalog. Pass describe:true to include each tool argument schema, needed before sando_call.', inputSchema: GATEWAY_CATALOG_SCHEMA },
|
|
272
|
+
{ name: GATEWAY_CALL_TOOL, description: 'Call one exact qualified name returned by sando_catalog.', inputSchema: GATEWAY_CALL_SCHEMA },
|
|
273
|
+
] });
|
|
274
|
+
if (message.method === 'tools/call' && message.params?.name === GATEWAY_CATALOG_TOOL) {
|
|
275
|
+
const arguments_ = message.params.arguments === undefined ? {} : message.params.arguments;
|
|
276
|
+
const validation = validateJsonSchema(GATEWAY_CATALOG_SCHEMA, arguments_);
|
|
277
|
+
if (!validation.valid) {
|
|
278
|
+
emitF4Event({ operation: 'catalog', outcome: 'rejected', startedAt: Date.now() });
|
|
279
|
+
return rpcError(message.id, -32602, `Invalid catalog arguments: ${validation.message}`);
|
|
280
|
+
}
|
|
281
|
+
return response(message.id, { content: [{ type: 'text', text: JSON.stringify(await catalog(arguments_.query, arguments_.limit, arguments_.describe)) }] });
|
|
282
|
+
}
|
|
283
|
+
if (message.method === 'tools/call' && message.params?.name === GATEWAY_CALL_TOOL) {
|
|
284
|
+
const validation = validateJsonSchema(GATEWAY_CALL_SCHEMA, message.params?.arguments);
|
|
285
|
+
if (!validation.valid) {
|
|
286
|
+
emitF4Event({ operation: 'call', outcome: 'rejected', startedAt: Date.now(), capabilityDigest: safeCapabilityDigest(message.params?.arguments?.name) });
|
|
287
|
+
return rpcError(message.id, -32602, `Invalid tool arguments: ${validation.message}`);
|
|
288
|
+
}
|
|
289
|
+
return call({ ...message, params: { ...message.params, name: message.params.arguments.name, arguments: message.params.arguments.arguments } });
|
|
290
|
+
}
|
|
291
|
+
if (message.method === 'tools/call') return call(message);
|
|
292
|
+
return rpcError(message.id, -32601, 'Method not found');
|
|
293
|
+
}
|
|
294
|
+
return Object.freeze({ handle, catalog, validateJsonSchema, close: async () => { for (const { transport } of connections.values()) await transport.close?.(); connections.clear(); tools.clear(); } });
|
|
295
|
+
}
|
package/src/mcp-server.mjs
CHANGED
|
@@ -1,46 +1,107 @@
|
|
|
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';
|
|
11
|
+
import { createSliceBridge, isSliceTool, SLICE_TOOLS, SliceRpcError } from './slice.mjs';
|
|
5
12
|
|
|
6
13
|
const TOOL = {
|
|
7
14
|
name: 'prepare_tool_output',
|
|
8
|
-
description: 'Prepare deterministic bounded inline output and
|
|
15
|
+
description: 'Prepare deterministic bounded inline output and optional redacted artifact handle. Full content is recovered with sando_artifact_get.',
|
|
9
16
|
inputSchema: {
|
|
10
17
|
type: 'object', additionalProperties: false, required: ['toolName', 'output', 'cwd'],
|
|
11
18
|
properties: { toolName: { type: 'string', minLength: 1, maxLength: 128 }, output: {}, cwd: { type: 'string', minLength: 1 }, policy: { type: 'object' } },
|
|
12
19
|
},
|
|
13
20
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
14
21
|
};
|
|
22
|
+
const ARTIFACT_TOOL = {
|
|
23
|
+
name: ARTIFACT_TOOL_NAME,
|
|
24
|
+
description: 'Recover bounded redacted content from an artifact created in this MCP session. Copy artifact.handle exactly into ref (for example, sando:sha256:0123456789abcdef). Omit range fields to select the full artifact; the response remains bounded by maxBytes (default 65536). Otherwise use either 0-based byte offsets or a 1-based inclusive line range, and omit fields for the unused mode.',
|
|
25
|
+
inputSchema: {
|
|
26
|
+
type: 'object', additionalProperties: false, required: ['ref'],
|
|
27
|
+
properties: {
|
|
28
|
+
ref: { type: 'string', pattern: '^sando:sha256:[a-f0-9]{16,64}$' },
|
|
29
|
+
startByte: { type: 'integer', minimum: 0, description: '0-based inclusive byte offset.' },
|
|
30
|
+
endByte: { type: 'integer', minimum: 0, description: '0-based exclusive byte offset.' },
|
|
31
|
+
startLine: { type: 'integer', minimum: 1, description: '1-based inclusive line number.' },
|
|
32
|
+
endLine: { type: 'integer', minimum: 1, description: '1-based inclusive line number.' },
|
|
33
|
+
maxBytes: { type: 'integer', minimum: 1, maximum: 1048576, description: 'Maximum output bytes; omit for the default.' },
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
37
|
+
};
|
|
38
|
+
const TOOLS = [TOOL, ARTIFACT_TOOL];
|
|
15
39
|
|
|
16
40
|
function response(id, result) { return { jsonrpc: '2.0', id, result }; }
|
|
17
|
-
function error(id, code, message) { return { jsonrpc: '2.0', id: id ?? null, error: { code, message } }; }
|
|
41
|
+
function error(id, code, message, data) { return { jsonrpc: '2.0', id: id ?? null, error: { code, message, ...(data === undefined ? {} : { data }) } }; }
|
|
42
|
+
function requestKey(id) { return `${typeof id}:${JSON.stringify(id)}`; }
|
|
18
43
|
|
|
19
|
-
function dispatch(message) {
|
|
44
|
+
async function dispatch(message, bridge, active) {
|
|
20
45
|
if (!message || message.jsonrpc !== '2.0' || typeof message.method !== 'string') return error(message?.id, -32600, 'Invalid Request');
|
|
46
|
+
if (message.method === 'notifications/cancelled') {
|
|
47
|
+
active.get(requestKey(message.params?.requestId))?.abort();
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
21
50
|
if (message.id === undefined) return null;
|
|
22
51
|
if (message.method === 'initialize') return response(message.id, {
|
|
23
52
|
protocolVersion: message.params?.protocolVersion || '2025-06-18', capabilities: { tools: { listChanged: false } }, serverInfo: { name: 'sando', version: PLUGIN_VERSION },
|
|
24
53
|
});
|
|
25
54
|
if (message.method === 'ping') return response(message.id, {});
|
|
26
|
-
|
|
55
|
+
const tools = [...TOOLS, ...SLICE_TOOLS()];
|
|
56
|
+
if (message.method === 'tools/list') return response(message.id, { tools });
|
|
27
57
|
if (message.method === 'tools/call') {
|
|
28
|
-
if (
|
|
58
|
+
if (!tools.some((tool) => tool.name === message.params?.name)) return error(message.id, -32602, 'Unknown tool');
|
|
59
|
+
const controller = new AbortController();
|
|
60
|
+
active.set(requestKey(message.id), controller);
|
|
29
61
|
try {
|
|
30
|
-
|
|
31
|
-
|
|
62
|
+
if (isSliceTool(message.params.name)) {
|
|
63
|
+
return response(message.id, await bridge.call(message.params.name, message.params.arguments, { signal: controller.signal }));
|
|
64
|
+
}
|
|
65
|
+
const result = message.params.name === TOOL.name
|
|
66
|
+
? optimizeToolOutput(message.params.arguments)
|
|
67
|
+
: recoverStoredArtifact(message.params.arguments);
|
|
68
|
+
const exposed = message.params.name === TOOL.name ? exposeMcpResult(result) : result;
|
|
69
|
+
return response(message.id, { content: [{ type: 'text', text: exposed.inline ?? exposed.content }], structuredContent: exposed, isError: false });
|
|
32
70
|
} catch (cause) {
|
|
71
|
+
if (cause instanceof SliceRpcError) return error(message.id, cause.code, cause.message, cause.data);
|
|
33
72
|
return response(message.id, { content: [{ type: 'text', text: cause instanceof Error ? cause.message : 'invalid tool input' }], isError: true });
|
|
34
|
-
}
|
|
73
|
+
} finally { active.delete(requestKey(message.id)); }
|
|
35
74
|
}
|
|
36
75
|
return error(message.id, -32601, 'Method not found');
|
|
37
76
|
}
|
|
38
77
|
|
|
39
78
|
export function startMcpServer() {
|
|
40
79
|
const lines = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
80
|
+
const bridge = createSliceBridge();
|
|
81
|
+
const active = new Map();
|
|
82
|
+
const pending = new Set();
|
|
41
83
|
lines.on('line', (line) => {
|
|
42
|
-
let
|
|
43
|
-
try {
|
|
44
|
-
|
|
84
|
+
let message;
|
|
85
|
+
try { message = JSON.parse(line); } catch { process.stdout.write(`${JSON.stringify(error(null, -32700, 'Parse error'))}\n`); return; }
|
|
86
|
+
const task = dispatch(message, bridge, active).then((output) => {
|
|
87
|
+
if (output) process.stdout.write(`${JSON.stringify(output)}\n`);
|
|
88
|
+
}).catch(() => process.stdout.write(`${JSON.stringify(error(message?.id, -32603, 'Internal error'))}\n`));
|
|
89
|
+
pending.add(task);
|
|
90
|
+
void task.finally(() => pending.delete(task));
|
|
45
91
|
});
|
|
92
|
+
lines.once('close', async () => { await Promise.allSettled(pending); bridge.close(); });
|
|
93
|
+
let stopping = false;
|
|
94
|
+
const stop = async () => {
|
|
95
|
+
if (stopping) return;
|
|
96
|
+
stopping = true;
|
|
97
|
+
lines.close();
|
|
98
|
+
for (const controller of active.values()) controller.abort();
|
|
99
|
+
bridge.close();
|
|
100
|
+
await Promise.allSettled(pending);
|
|
101
|
+
process.exit(0);
|
|
102
|
+
};
|
|
103
|
+
process.once('SIGTERM', stop);
|
|
104
|
+
process.once('SIGINT', stop);
|
|
46
105
|
}
|
|
106
|
+
|
|
107
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) startMcpServer();
|
package/src/metrics.mjs
CHANGED
|
@@ -204,13 +204,14 @@ function providerSavings(providerUsage) {
|
|
|
204
204
|
const value = (names) => names.map((name) => providerUsage[name]).find((candidate) => candidate !== undefined);
|
|
205
205
|
const baseline = value(['baselineInputTokens', 'baseline_input_tokens']);
|
|
206
206
|
const optimized = value(['optimizedInputTokens', 'optimized_input_tokens']);
|
|
207
|
+
const reported = value(['reportedSavingsTokens', 'reported_savings_tokens']);
|
|
208
|
+
if (baseline === undefined && optimized === undefined && reported === undefined) return null;
|
|
207
209
|
if (baseline !== undefined || optimized !== undefined) {
|
|
208
210
|
integer(baseline, 'baselineInputTokens');
|
|
209
211
|
integer(optimized, 'optimizedInputTokens');
|
|
210
|
-
return
|
|
212
|
+
return null;
|
|
211
213
|
}
|
|
212
|
-
|
|
213
|
-
if (reported !== undefined) return integer(reported, 'reportedSavingsTokens', { min: -Number.MAX_SAFE_INTEGER });
|
|
214
|
+
integer(reported, 'reportedSavingsTokens', { min: -Number.MAX_SAFE_INTEGER });
|
|
214
215
|
return null;
|
|
215
216
|
}
|
|
216
217
|
|