mcp-proxy-conductor 0.1.0 → 0.1.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/dist/index.js +227 -57
- package/package.json +7 -6
package/dist/index.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
+
import { realpathSync } from "fs";
|
|
5
|
+
import { fileURLToPath } from "url";
|
|
4
6
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
7
|
|
|
6
8
|
// src/config/store.ts
|
|
@@ -111,98 +113,185 @@ import {
|
|
|
111
113
|
ResourceListChangedNotificationSchema,
|
|
112
114
|
PromptListChangedNotificationSchema
|
|
113
115
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
116
|
+
|
|
117
|
+
// src/version.ts
|
|
118
|
+
var VERSION = "0.1.2";
|
|
119
|
+
|
|
120
|
+
// src/registry/connection.ts
|
|
121
|
+
var EMPTY = { tools: [], resources: [], prompts: [] };
|
|
114
122
|
var DownstreamConnection = class {
|
|
115
|
-
constructor(def, transportFactory,
|
|
123
|
+
constructor(def, transportFactory, opts = {}) {
|
|
116
124
|
this.def = def;
|
|
117
125
|
this.transportFactory = transportFactory;
|
|
118
|
-
this.
|
|
119
|
-
this.
|
|
126
|
+
this.capabilities = opts.cachedCaps ?? { ...EMPTY };
|
|
127
|
+
this.hydrated = opts.cachedCaps !== void 0;
|
|
128
|
+
this.idleMs = opts.idleMs ?? 0;
|
|
129
|
+
this.onChange = opts.onChange;
|
|
120
130
|
}
|
|
121
131
|
def;
|
|
122
132
|
transportFactory;
|
|
123
|
-
|
|
124
|
-
state = "connecting";
|
|
133
|
+
state = "idle";
|
|
125
134
|
error;
|
|
126
|
-
capabilities
|
|
135
|
+
capabilities;
|
|
136
|
+
hydrated;
|
|
137
|
+
// true while capabilities come from cache (not a live fetch)
|
|
138
|
+
idleMs;
|
|
139
|
+
onChange;
|
|
127
140
|
client;
|
|
128
|
-
|
|
141
|
+
idleTimer;
|
|
142
|
+
connecting;
|
|
143
|
+
// Connect if not already connected. Concurrent callers share one in-flight connect.
|
|
144
|
+
// Every call (re)starts the idle timer.
|
|
145
|
+
async ensureConnected() {
|
|
146
|
+
this.touch();
|
|
147
|
+
if (this.state === "connected") return;
|
|
148
|
+
if (!this.connecting) {
|
|
149
|
+
this.connecting = this.doConnect().finally(() => {
|
|
150
|
+
this.connecting = void 0;
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
return this.connecting;
|
|
154
|
+
}
|
|
155
|
+
async callTool(name, args) {
|
|
156
|
+
await this.ensureConnected();
|
|
157
|
+
this.touch();
|
|
158
|
+
return this.client.callTool({ name, arguments: args });
|
|
159
|
+
}
|
|
160
|
+
async readResource(uri) {
|
|
161
|
+
await this.ensureConnected();
|
|
162
|
+
this.touch();
|
|
163
|
+
return this.client.readResource({ uri });
|
|
164
|
+
}
|
|
165
|
+
async getPrompt(name, args) {
|
|
166
|
+
await this.ensureConnected();
|
|
167
|
+
this.touch();
|
|
168
|
+
return this.client.getPrompt({ name, arguments: args });
|
|
169
|
+
}
|
|
170
|
+
async refresh() {
|
|
171
|
+
const before = JSON.stringify(this.capabilities);
|
|
172
|
+
const caps = this.client.getServerCapabilities() ?? {};
|
|
173
|
+
this.capabilities = {
|
|
174
|
+
tools: caps.tools ? (await this.client.listTools()).tools : [],
|
|
175
|
+
resources: caps.resources ? (await this.client.listResources()).resources : [],
|
|
176
|
+
prompts: caps.prompts ? (await this.client.listPrompts()).prompts : []
|
|
177
|
+
};
|
|
178
|
+
return JSON.stringify(this.capabilities) !== before;
|
|
179
|
+
}
|
|
180
|
+
async close() {
|
|
181
|
+
this.clearIdle();
|
|
182
|
+
await this.client?.close().catch(() => void 0);
|
|
183
|
+
this.client = void 0;
|
|
184
|
+
if (this.state !== "error") this.state = "idle";
|
|
185
|
+
}
|
|
186
|
+
async doConnect() {
|
|
129
187
|
this.state = "connecting";
|
|
188
|
+
const client = new Client({ name: "ai-conductor", version: VERSION });
|
|
130
189
|
try {
|
|
131
190
|
const transport = await this.transportFactory(this.def);
|
|
132
|
-
await
|
|
133
|
-
this.client
|
|
134
|
-
|
|
191
|
+
await client.connect(transport);
|
|
192
|
+
this.client = client;
|
|
193
|
+
client.onclose = () => {
|
|
194
|
+
if (this.state === "connected") this.state = "idle";
|
|
135
195
|
this.onChange?.();
|
|
136
196
|
};
|
|
137
|
-
this.registerNotificationHandlers();
|
|
138
|
-
await this.refresh();
|
|
197
|
+
this.registerNotificationHandlers(client);
|
|
198
|
+
const changed = await this.refresh();
|
|
139
199
|
this.state = "connected";
|
|
200
|
+
this.error = void 0;
|
|
201
|
+
this.hydrated = false;
|
|
202
|
+
this.touch();
|
|
203
|
+
if (changed) this.onChange?.();
|
|
140
204
|
} catch (err) {
|
|
141
205
|
this.state = "error";
|
|
142
206
|
this.error = err instanceof Error ? err.message : String(err);
|
|
207
|
+
await client.close().catch(() => void 0);
|
|
208
|
+
this.client = void 0;
|
|
143
209
|
throw err;
|
|
144
210
|
}
|
|
145
211
|
}
|
|
146
|
-
registerNotificationHandlers() {
|
|
212
|
+
registerNotificationHandlers(client) {
|
|
147
213
|
const refreshAndNotify = async () => {
|
|
148
214
|
await this.refresh().catch(() => void 0);
|
|
149
215
|
this.onChange?.();
|
|
150
216
|
};
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
this.
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
217
|
+
client.setNotificationHandler(ToolListChangedNotificationSchema, refreshAndNotify);
|
|
218
|
+
client.setNotificationHandler(ResourceListChangedNotificationSchema, refreshAndNotify);
|
|
219
|
+
client.setNotificationHandler(PromptListChangedNotificationSchema, refreshAndNotify);
|
|
220
|
+
}
|
|
221
|
+
touch() {
|
|
222
|
+
this.clearIdle();
|
|
223
|
+
if (this.idleMs > 0) {
|
|
224
|
+
this.idleTimer = setTimeout(() => {
|
|
225
|
+
void this.close();
|
|
226
|
+
}, this.idleMs);
|
|
227
|
+
this.idleTimer.unref?.();
|
|
228
|
+
}
|
|
162
229
|
}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
230
|
+
clearIdle() {
|
|
231
|
+
if (this.idleTimer) {
|
|
232
|
+
clearTimeout(this.idleTimer);
|
|
233
|
+
this.idleTimer = void 0;
|
|
234
|
+
}
|
|
166
235
|
}
|
|
167
236
|
};
|
|
168
237
|
|
|
169
238
|
// src/registry/manager.ts
|
|
170
239
|
var DownstreamManager = class {
|
|
171
|
-
constructor(transportFactory,
|
|
240
|
+
constructor(transportFactory, opts = {}) {
|
|
172
241
|
this.transportFactory = transportFactory;
|
|
173
|
-
this.onChange = onChange;
|
|
242
|
+
this.onChange = opts.onChange ?? (() => void 0);
|
|
243
|
+
this.cache = opts.cache;
|
|
244
|
+
this.idleMs = opts.idleMs ?? 0;
|
|
174
245
|
}
|
|
175
246
|
transportFactory;
|
|
176
|
-
onChange;
|
|
177
247
|
conns = /* @__PURE__ */ new Map();
|
|
178
|
-
|
|
179
|
-
|
|
248
|
+
onChange;
|
|
249
|
+
cache;
|
|
250
|
+
idleMs;
|
|
251
|
+
cacheMap = {};
|
|
252
|
+
// Non-blocking: create idle connections hydrated from cache; only servers with no cached
|
|
253
|
+
// capabilities are background-filled (fire-and-forget) so they can be advertised.
|
|
180
254
|
async start(defs) {
|
|
181
|
-
|
|
255
|
+
this.cacheMap = this.cache ? await this.cache.load() : {};
|
|
256
|
+
for (const def of defs.filter((d) => d.enabled)) {
|
|
257
|
+
const conn = this.createConn(def);
|
|
258
|
+
this.conns.set(def.id, conn);
|
|
259
|
+
if (!this.cacheMap[def.id]) {
|
|
260
|
+
void conn.ensureConnected().catch(() => void 0);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
182
263
|
}
|
|
183
|
-
async
|
|
184
|
-
|
|
264
|
+
async add(def) {
|
|
265
|
+
if (this.conns.has(def.id)) throw new Error(`server '${def.id}' already exists`);
|
|
266
|
+
const conn = this.createConn(def);
|
|
185
267
|
this.conns.set(def.id, conn);
|
|
186
|
-
await conn.
|
|
268
|
+
await conn.ensureConnected().catch(() => void 0);
|
|
269
|
+
if (conn.state === "connected" && this.cache) {
|
|
270
|
+
this.cacheMap[def.id] = {
|
|
271
|
+
tools: conn.capabilities.tools,
|
|
272
|
+
resources: conn.capabilities.resources,
|
|
273
|
+
prompts: conn.capabilities.prompts,
|
|
274
|
+
fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
275
|
+
};
|
|
276
|
+
await this.cache.save(this.cacheMap).catch(() => void 0);
|
|
277
|
+
}
|
|
187
278
|
this.onChange();
|
|
188
279
|
return conn;
|
|
189
280
|
}
|
|
190
|
-
async add(def) {
|
|
191
|
-
if (this.conns.has(def.id)) throw new Error(`server '${def.id}' already exists`);
|
|
192
|
-
return this.connectOne(def);
|
|
193
|
-
}
|
|
194
281
|
async remove(id) {
|
|
195
282
|
const conn = this.conns.get(id);
|
|
196
283
|
if (!conn) return;
|
|
197
284
|
await conn.close();
|
|
198
285
|
this.conns.delete(id);
|
|
286
|
+
delete this.cacheMap[id];
|
|
287
|
+
if (this.cache) await this.cache.save(this.cacheMap).catch(() => void 0);
|
|
199
288
|
this.onChange();
|
|
200
289
|
}
|
|
201
290
|
get(id) {
|
|
202
291
|
return this.conns.get(id);
|
|
203
292
|
}
|
|
204
|
-
|
|
205
|
-
return [...this.conns.values()]
|
|
293
|
+
entries() {
|
|
294
|
+
return [...this.conns.values()];
|
|
206
295
|
}
|
|
207
296
|
list() {
|
|
208
297
|
return [...this.conns.values()].map((c) => ({
|
|
@@ -211,13 +300,36 @@ var DownstreamManager = class {
|
|
|
211
300
|
error: c.error,
|
|
212
301
|
toolCount: c.capabilities.tools.length,
|
|
213
302
|
resourceCount: c.capabilities.resources.length,
|
|
214
|
-
promptCount: c.capabilities.prompts.length
|
|
303
|
+
promptCount: c.capabilities.prompts.length,
|
|
304
|
+
cached: c.hydrated
|
|
215
305
|
}));
|
|
216
306
|
}
|
|
217
307
|
async closeAll() {
|
|
218
308
|
await Promise.all([...this.conns.values()].map((c) => c.close()));
|
|
219
309
|
this.conns.clear();
|
|
220
310
|
}
|
|
311
|
+
createConn(def) {
|
|
312
|
+
return new DownstreamConnection(def, this.transportFactory, {
|
|
313
|
+
idleMs: this.idleMs,
|
|
314
|
+
cachedCaps: this.cacheMap[def.id],
|
|
315
|
+
onChange: () => this.onConnChange(def.id)
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
// A connection reports a capability/state change: persist live caps to the cache and
|
|
319
|
+
// propagate listChanged upstream.
|
|
320
|
+
onConnChange(id) {
|
|
321
|
+
const conn = this.conns.get(id);
|
|
322
|
+
if (conn && conn.state === "connected") {
|
|
323
|
+
this.cacheMap[id] = {
|
|
324
|
+
tools: conn.capabilities.tools,
|
|
325
|
+
resources: conn.capabilities.resources,
|
|
326
|
+
prompts: conn.capabilities.prompts,
|
|
327
|
+
fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
328
|
+
};
|
|
329
|
+
if (this.cache) void this.cache.save(this.cacheMap).catch(() => void 0);
|
|
330
|
+
}
|
|
331
|
+
this.onChange();
|
|
332
|
+
}
|
|
221
333
|
};
|
|
222
334
|
|
|
223
335
|
// src/aggregator/namespace.ts
|
|
@@ -250,38 +362,39 @@ var Aggregator = class {
|
|
|
250
362
|
this.manager = manager;
|
|
251
363
|
}
|
|
252
364
|
manager;
|
|
365
|
+
// Advertise from every known connection's capabilities (cached while idle, live when
|
|
366
|
+
// connected). No connection is established just to list.
|
|
253
367
|
async listTools() {
|
|
254
|
-
return this.manager.
|
|
368
|
+
return this.manager.entries().flatMap(
|
|
255
369
|
(c) => c.capabilities.tools.map((t) => ({ ...t, name: encodeName(c.def.id, t.name) }))
|
|
256
370
|
);
|
|
257
371
|
}
|
|
258
372
|
async listPrompts() {
|
|
259
|
-
return this.manager.
|
|
373
|
+
return this.manager.entries().flatMap(
|
|
260
374
|
(c) => c.capabilities.prompts.map((p) => ({ ...p, name: encodeName(c.def.id, p.name) }))
|
|
261
375
|
);
|
|
262
376
|
}
|
|
263
377
|
async listResources() {
|
|
264
|
-
return this.manager.
|
|
378
|
+
return this.manager.entries().flatMap(
|
|
265
379
|
(c) => c.capabilities.resources.map((r) => ({ ...r, uri: encodeUri(c.def.id, r.uri) }))
|
|
266
380
|
);
|
|
267
381
|
}
|
|
382
|
+
// Routing connects on demand via the connection's own methods (ensureConnected inside).
|
|
268
383
|
async callTool(qualifiedName, args) {
|
|
269
384
|
const { serverId, name } = decodeName(qualifiedName);
|
|
270
|
-
return this.connOrThrow(serverId).
|
|
385
|
+
return this.connOrThrow(serverId).callTool(name, args);
|
|
271
386
|
}
|
|
272
387
|
async getPrompt(qualifiedName, args) {
|
|
273
388
|
const { serverId, name } = decodeName(qualifiedName);
|
|
274
|
-
return this.connOrThrow(serverId).
|
|
389
|
+
return this.connOrThrow(serverId).getPrompt(name, args);
|
|
275
390
|
}
|
|
276
391
|
async readResource(qualifiedUri) {
|
|
277
392
|
const { serverId, uri } = decodeUri(qualifiedUri);
|
|
278
|
-
return this.connOrThrow(serverId).
|
|
393
|
+
return this.connOrThrow(serverId).readResource(uri);
|
|
279
394
|
}
|
|
280
395
|
connOrThrow(serverId) {
|
|
281
396
|
const conn = this.manager.get(serverId);
|
|
282
|
-
if (!conn
|
|
283
|
-
throw new Error(`downstream server '${serverId}' is not connected`);
|
|
284
|
-
}
|
|
397
|
+
if (!conn) throw new Error(`unknown downstream server '${serverId}'`);
|
|
285
398
|
return conn;
|
|
286
399
|
}
|
|
287
400
|
};
|
|
@@ -317,7 +430,7 @@ var MetaTools = class {
|
|
|
317
430
|
},
|
|
318
431
|
{
|
|
319
432
|
name: "list_servers",
|
|
320
|
-
description: "List managed downstream servers
|
|
433
|
+
description: "List managed downstream servers: connection state (idle/connected/error), whether capabilities are served from cache, and capability counts.",
|
|
321
434
|
inputSchema: { type: "object", properties: {} }
|
|
322
435
|
}
|
|
323
436
|
];
|
|
@@ -367,7 +480,7 @@ import {
|
|
|
367
480
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
368
481
|
function buildUpstreamServer(aggregator, meta) {
|
|
369
482
|
const server = new Server(
|
|
370
|
-
{ name: "ai-conductor", version:
|
|
483
|
+
{ name: "ai-conductor", version: VERSION },
|
|
371
484
|
{ capabilities: { tools: { listChanged: true }, resources: { listChanged: true }, prompts: { listChanged: true } } }
|
|
372
485
|
);
|
|
373
486
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
@@ -389,6 +502,51 @@ function buildUpstreamServer(aggregator, meta) {
|
|
|
389
502
|
return server;
|
|
390
503
|
}
|
|
391
504
|
|
|
505
|
+
// src/config/capability-cache.ts
|
|
506
|
+
import { mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
|
|
507
|
+
import { dirname as dirname2 } from "path";
|
|
508
|
+
import envPaths2 from "env-paths";
|
|
509
|
+
import { z as z2 } from "zod";
|
|
510
|
+
var CachedCapabilitiesSchema = z2.object({
|
|
511
|
+
tools: z2.array(z2.any()),
|
|
512
|
+
resources: z2.array(z2.any()),
|
|
513
|
+
prompts: z2.array(z2.any()),
|
|
514
|
+
fetchedAt: z2.string()
|
|
515
|
+
});
|
|
516
|
+
var CacheMapSchema = z2.record(z2.string(), CachedCapabilitiesSchema);
|
|
517
|
+
function defaultCapabilityCachePath() {
|
|
518
|
+
return `${envPaths2("ai-conductor", { suffix: "" }).config}/capabilities.json`;
|
|
519
|
+
}
|
|
520
|
+
var CapabilityCache = class {
|
|
521
|
+
constructor(path = defaultCapabilityCachePath()) {
|
|
522
|
+
this.path = path;
|
|
523
|
+
}
|
|
524
|
+
path;
|
|
525
|
+
async load() {
|
|
526
|
+
let raw;
|
|
527
|
+
try {
|
|
528
|
+
raw = await readFile2(this.path, "utf8");
|
|
529
|
+
} catch (err) {
|
|
530
|
+
if (err.code === "ENOENT") return {};
|
|
531
|
+
throw err;
|
|
532
|
+
}
|
|
533
|
+
const parsed = CacheMapSchema.safeParse(JSON.parse(tryJson(raw)));
|
|
534
|
+
return parsed.success ? parsed.data : {};
|
|
535
|
+
}
|
|
536
|
+
async save(map) {
|
|
537
|
+
await mkdir2(dirname2(this.path), { recursive: true });
|
|
538
|
+
await writeFile2(this.path, JSON.stringify(map, null, 2), "utf8");
|
|
539
|
+
}
|
|
540
|
+
};
|
|
541
|
+
function tryJson(raw) {
|
|
542
|
+
try {
|
|
543
|
+
JSON.parse(raw);
|
|
544
|
+
return raw;
|
|
545
|
+
} catch {
|
|
546
|
+
return "null";
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
392
550
|
// src/index.ts
|
|
393
551
|
async function createConductor(opts = {}) {
|
|
394
552
|
const store = opts.store ?? new ConfigStore();
|
|
@@ -401,7 +559,10 @@ async function createConductor(opts = {}) {
|
|
|
401
559
|
server.sendResourceListChanged();
|
|
402
560
|
server.sendPromptListChanged();
|
|
403
561
|
};
|
|
404
|
-
const
|
|
562
|
+
const cache = opts.cache ?? new CapabilityCache();
|
|
563
|
+
const rawIdleMs = Number(process.env.CONDUCTOR_IDLE_TIMEOUT_MS ?? 3e5);
|
|
564
|
+
const idleMs = opts.idleMs ?? (Number.isFinite(rawIdleMs) ? rawIdleMs : 3e5);
|
|
565
|
+
const manager = new DownstreamManager(transportFactory, { onChange, cache, idleMs });
|
|
405
566
|
const aggregator = new Aggregator(manager);
|
|
406
567
|
const meta = new MetaTools(manager, store);
|
|
407
568
|
server = buildUpstreamServer(aggregator, meta);
|
|
@@ -415,12 +576,21 @@ async function createConductor(opts = {}) {
|
|
|
415
576
|
}
|
|
416
577
|
};
|
|
417
578
|
}
|
|
418
|
-
|
|
579
|
+
function isMainModule(argv1, importMetaUrl) {
|
|
580
|
+
if (!argv1) return false;
|
|
581
|
+
try {
|
|
582
|
+
return realpathSync(argv1) === fileURLToPath(importMetaUrl);
|
|
583
|
+
} catch {
|
|
584
|
+
return false;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
if (isMainModule(process.argv[1], import.meta.url)) {
|
|
419
588
|
createConductor().then((c) => c.start()).catch((err) => {
|
|
420
589
|
console.error("ai-conductor failed to start:", err);
|
|
421
590
|
process.exit(1);
|
|
422
591
|
});
|
|
423
592
|
}
|
|
424
593
|
export {
|
|
425
|
-
createConductor
|
|
594
|
+
createConductor,
|
|
595
|
+
isMainModule
|
|
426
596
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-proxy-conductor",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Local MCP proxy that aggregates dynamically managed downstream MCP servers, managed at runtime without restarting the client.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -32,11 +32,12 @@
|
|
|
32
32
|
"node": ">=20"
|
|
33
33
|
},
|
|
34
34
|
"scripts": {
|
|
35
|
-
"
|
|
36
|
-
"
|
|
37
|
-
"
|
|
38
|
-
"test:
|
|
39
|
-
"
|
|
35
|
+
"gen:version": "node scripts/gen-version.mjs",
|
|
36
|
+
"build": "pnpm gen:version && tsup",
|
|
37
|
+
"dev": "pnpm gen:version && tsx src/index.ts",
|
|
38
|
+
"test": "pnpm gen:version && vitest run",
|
|
39
|
+
"test:watch": "pnpm gen:version && vitest",
|
|
40
|
+
"typecheck": "pnpm gen:version && tsc --noEmit",
|
|
40
41
|
"prepublishOnly": "pnpm typecheck && pnpm test && pnpm build"
|
|
41
42
|
},
|
|
42
43
|
"dependencies": {
|