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.
Files changed (2) hide show
  1. package/dist/index.js +227 -57
  2. 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, onChange) {
123
+ constructor(def, transportFactory, opts = {}) {
116
124
  this.def = def;
117
125
  this.transportFactory = transportFactory;
118
- this.onChange = onChange;
119
- this.client = new Client({ name: "ai-conductor", version: "0.1.0" });
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
- onChange;
124
- state = "connecting";
133
+ state = "idle";
125
134
  error;
126
- capabilities = { tools: [], resources: [], prompts: [] };
135
+ capabilities;
136
+ hydrated;
137
+ // true while capabilities come from cache (not a live fetch)
138
+ idleMs;
139
+ onChange;
127
140
  client;
128
- async connect() {
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 this.client.connect(transport);
133
- this.client.onclose = () => {
134
- if (this.state === "connected") this.state = "disconnected";
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
- this.client.setNotificationHandler(ToolListChangedNotificationSchema, refreshAndNotify);
152
- this.client.setNotificationHandler(ResourceListChangedNotificationSchema, refreshAndNotify);
153
- this.client.setNotificationHandler(PromptListChangedNotificationSchema, refreshAndNotify);
154
- }
155
- async refresh() {
156
- const caps = this.client.getServerCapabilities() ?? {};
157
- this.capabilities = {
158
- tools: caps.tools ? (await this.client.listTools()).tools : [],
159
- resources: caps.resources ? (await this.client.listResources()).resources : [],
160
- prompts: caps.prompts ? (await this.client.listPrompts()).prompts : []
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
- async close() {
164
- await this.client.close().catch(() => void 0);
165
- this.state = "disconnected";
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, onChange = () => void 0) {
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
- // Connect all enabled definitions. Individual failures are captured as error state;
179
- // they never reject start() or prevent other servers from connecting.
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
- await Promise.all(defs.filter((d) => d.enabled).map((d) => this.connectOne(d)));
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 connectOne(def) {
184
- const conn = new DownstreamConnection(def, this.transportFactory, this.onChange);
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.connect().catch(() => void 0);
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
- connected() {
205
- return [...this.conns.values()].filter((c) => c.state === "connected");
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.connected().flatMap(
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.connected().flatMap(
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.connected().flatMap(
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).client.callTool({ name, arguments: args });
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).client.getPrompt({ name, arguments: args });
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).client.readResource({ uri });
393
+ return this.connOrThrow(serverId).readResource(uri);
279
394
  }
280
395
  connOrThrow(serverId) {
281
396
  const conn = this.manager.get(serverId);
282
- if (!conn || conn.state !== "connected") {
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 with connection state and capability counts.",
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: "0.1.0" },
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 manager = new DownstreamManager(transportFactory, onChange);
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
- if (import.meta.url === `file://${process.argv[1]}`) {
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.0",
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
- "build": "tsup",
36
- "dev": "tsx src/index.ts",
37
- "test": "vitest run",
38
- "test:watch": "vitest",
39
- "typecheck": "tsc --noEmit",
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": {