154 lines
4.4 KiB
JavaScript
154 lines
4.4 KiB
JavaScript
/**
|
|
* MCP tool registry: HTTP JSON-RPC (initialize + tools/list + tools/call).
|
|
* Stdio is trusted-origin only and is not spawned in this wave.
|
|
*/
|
|
|
|
const rpc = require('./mcp-rpc.js');
|
|
const truncate = require('./truncate.js');
|
|
|
|
const servers = new Map();
|
|
let rpcId = 1;
|
|
|
|
function nextId() {
|
|
rpcId += 1;
|
|
return rpcId;
|
|
}
|
|
|
|
function register(spec, opts) {
|
|
if (!spec || !spec.id) throw new Error('mcp id required');
|
|
const transport = spec.transport || 'http';
|
|
if (transport === 'stdio') {
|
|
const approved = opts && (opts.alwaysApprove || opts._alwaysApprove);
|
|
if (!approved) throw new Error('stdio MCP requires a trusted origin (always-approve)');
|
|
throw new Error('stdio MCP is opt-in and not connected until explicitly implemented');
|
|
}
|
|
if (transport === 'http' && spec.url) {
|
|
require('../lib/net.js').assertPublicHttpUrl(spec.url);
|
|
}
|
|
const rec = {
|
|
id: spec.id,
|
|
name: spec.name || spec.id,
|
|
transport,
|
|
url: spec.url || null,
|
|
command: spec.command || null,
|
|
tools: Array.isArray(spec.tools) ? spec.tools : [],
|
|
handshakeError: null,
|
|
handshakeReported: false,
|
|
ready: null,
|
|
};
|
|
servers.set(spec.id, rec);
|
|
if (transport === 'http' && spec.url) {
|
|
rec.ready = handshake(rec).catch((err) => {
|
|
rec.handshakeError = err && err.message ? err.message : String(err);
|
|
});
|
|
return rec.ready.then(() => list());
|
|
}
|
|
return Promise.resolve(list());
|
|
}
|
|
|
|
async function handshake(rec) {
|
|
await jsonRpc(rec.url, 'initialize', {
|
|
protocolVersion: '2024-11-05',
|
|
capabilities: { tools: {} },
|
|
clientInfo: { name: 'agent-harness', version: '0.1.0' },
|
|
});
|
|
try {
|
|
await jsonRpc(rec.url, 'notifications/initialized', {});
|
|
} catch (_) {}
|
|
const listed = await jsonRpc(rec.url, 'tools/list', {});
|
|
rec.tools = rpc.normalizeTools(listed);
|
|
rec.handshakeError = null;
|
|
return rec.tools;
|
|
}
|
|
|
|
async function jsonRpc(url, method, params) {
|
|
const net = require('../lib/net.js');
|
|
net.assertPublicHttpUrl(url);
|
|
const payload = { jsonrpc: '2.0', id: nextId(), method, params: params || {} };
|
|
const res = await fetch(url, {
|
|
method: 'POST',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
accept: 'application/json, text/event-stream',
|
|
},
|
|
body: JSON.stringify(payload),
|
|
});
|
|
const text = await res.text();
|
|
const body = rpc.extractJson(text);
|
|
return rpc.unwrapResult(body);
|
|
}
|
|
|
|
function unregister(id) {
|
|
servers.delete(id);
|
|
return list();
|
|
}
|
|
|
|
function list() {
|
|
return Array.from(servers.values()).map((s) => ({
|
|
id: s.id,
|
|
name: s.name,
|
|
transport: s.transport,
|
|
toolCount: (s.tools || []).length,
|
|
tools: (s.tools || []).map((t) => t.name || t),
|
|
handshakeError: s.handshakeError || null,
|
|
}));
|
|
}
|
|
|
|
function search(query) {
|
|
const q = String(query || '').toLowerCase();
|
|
const hits = [];
|
|
for (const s of servers.values()) {
|
|
for (const t of s.tools || []) {
|
|
const name = typeof t === 'string' ? t : t.name;
|
|
const desc = typeof t === 'object' ? t.description || '' : '';
|
|
const wire = s.id + '__' + name;
|
|
if (!q || wire.toLowerCase().includes(q) || desc.toLowerCase().includes(q)) {
|
|
hits.push({ name: wire, server: s.id, description: desc });
|
|
}
|
|
}
|
|
}
|
|
return hits;
|
|
}
|
|
|
|
async function call(wireName, args) {
|
|
const idx = String(wireName || '').indexOf('__');
|
|
if (idx < 0) throw new Error('expected server__tool name');
|
|
const serverId = wireName.slice(0, idx);
|
|
const tool = wireName.slice(idx + 2);
|
|
const s = servers.get(serverId);
|
|
if (!s) throw new Error('unknown MCP server: ' + serverId);
|
|
if (s.ready) {
|
|
try {
|
|
await s.ready;
|
|
} catch (_) {}
|
|
}
|
|
if (s.handshakeError) throw new Error('MCP handshake failed: ' + s.handshakeError);
|
|
if (s.transport === 'http' && s.url) {
|
|
const result = await jsonRpc(s.url, 'tools/call', { name: tool, arguments: args || {} });
|
|
return truncate.truncateWithMarker(typeof result === 'string' ? result : JSON.stringify(result), 12000);
|
|
}
|
|
throw new Error('MCP transport not connected: ' + s.transport);
|
|
}
|
|
|
|
function handshakeReminders() {
|
|
const out = [];
|
|
for (const s of servers.values()) {
|
|
if (s.handshakeError && !s.handshakeReported) {
|
|
s.handshakeReported = true;
|
|
out.push('MCP ' + s.id + ' handshake failed: ' + s.handshakeError);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function count() {
|
|
return servers.size;
|
|
}
|
|
|
|
function reset() {
|
|
servers.clear();
|
|
rpcId = 1;
|
|
}
|
|
|
|
module.exports = { register, unregister, list, search, call, count, handshakeReminders, reset };
|