mcp-proxy-conductor 0.1.1 → 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 +214 -55
  2. package/package.json +7 -6
package/dist/index.js CHANGED
@@ -113,98 +113,185 @@ import {
113
113
  ResourceListChangedNotificationSchema,
114
114
  PromptListChangedNotificationSchema
115
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: [] };
116
122
  var DownstreamConnection = class {
117
- constructor(def, transportFactory, onChange) {
123
+ constructor(def, transportFactory, opts = {}) {
118
124
  this.def = def;
119
125
  this.transportFactory = transportFactory;
120
- this.onChange = onChange;
121
- 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;
122
130
  }
123
131
  def;
124
132
  transportFactory;
125
- onChange;
126
- state = "connecting";
133
+ state = "idle";
127
134
  error;
128
- capabilities = { tools: [], resources: [], prompts: [] };
135
+ capabilities;
136
+ hydrated;
137
+ // true while capabilities come from cache (not a live fetch)
138
+ idleMs;
139
+ onChange;
129
140
  client;
130
- 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() {
131
187
  this.state = "connecting";
188
+ const client = new Client({ name: "ai-conductor", version: VERSION });
132
189
  try {
133
190
  const transport = await this.transportFactory(this.def);
134
- await this.client.connect(transport);
135
- this.client.onclose = () => {
136
- 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";
137
195
  this.onChange?.();
138
196
  };
139
- this.registerNotificationHandlers();
140
- await this.refresh();
197
+ this.registerNotificationHandlers(client);
198
+ const changed = await this.refresh();
141
199
  this.state = "connected";
200
+ this.error = void 0;
201
+ this.hydrated = false;
202
+ this.touch();
203
+ if (changed) this.onChange?.();
142
204
  } catch (err) {
143
205
  this.state = "error";
144
206
  this.error = err instanceof Error ? err.message : String(err);
207
+ await client.close().catch(() => void 0);
208
+ this.client = void 0;
145
209
  throw err;
146
210
  }
147
211
  }
148
- registerNotificationHandlers() {
212
+ registerNotificationHandlers(client) {
149
213
  const refreshAndNotify = async () => {
150
214
  await this.refresh().catch(() => void 0);
151
215
  this.onChange?.();
152
216
  };
153
- this.client.setNotificationHandler(ToolListChangedNotificationSchema, refreshAndNotify);
154
- this.client.setNotificationHandler(ResourceListChangedNotificationSchema, refreshAndNotify);
155
- this.client.setNotificationHandler(PromptListChangedNotificationSchema, refreshAndNotify);
156
- }
157
- async refresh() {
158
- const caps = this.client.getServerCapabilities() ?? {};
159
- this.capabilities = {
160
- tools: caps.tools ? (await this.client.listTools()).tools : [],
161
- resources: caps.resources ? (await this.client.listResources()).resources : [],
162
- prompts: caps.prompts ? (await this.client.listPrompts()).prompts : []
163
- };
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
+ }
164
229
  }
165
- async close() {
166
- await this.client.close().catch(() => void 0);
167
- this.state = "disconnected";
230
+ clearIdle() {
231
+ if (this.idleTimer) {
232
+ clearTimeout(this.idleTimer);
233
+ this.idleTimer = void 0;
234
+ }
168
235
  }
169
236
  };
170
237
 
171
238
  // src/registry/manager.ts
172
239
  var DownstreamManager = class {
173
- constructor(transportFactory, onChange = () => void 0) {
240
+ constructor(transportFactory, opts = {}) {
174
241
  this.transportFactory = transportFactory;
175
- this.onChange = onChange;
242
+ this.onChange = opts.onChange ?? (() => void 0);
243
+ this.cache = opts.cache;
244
+ this.idleMs = opts.idleMs ?? 0;
176
245
  }
177
246
  transportFactory;
178
- onChange;
179
247
  conns = /* @__PURE__ */ new Map();
180
- // Connect all enabled definitions. Individual failures are captured as error state;
181
- // 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.
182
254
  async start(defs) {
183
- 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
+ }
184
263
  }
185
- async connectOne(def) {
186
- 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);
187
267
  this.conns.set(def.id, conn);
188
- 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
+ }
189
278
  this.onChange();
190
279
  return conn;
191
280
  }
192
- async add(def) {
193
- if (this.conns.has(def.id)) throw new Error(`server '${def.id}' already exists`);
194
- return this.connectOne(def);
195
- }
196
281
  async remove(id) {
197
282
  const conn = this.conns.get(id);
198
283
  if (!conn) return;
199
284
  await conn.close();
200
285
  this.conns.delete(id);
286
+ delete this.cacheMap[id];
287
+ if (this.cache) await this.cache.save(this.cacheMap).catch(() => void 0);
201
288
  this.onChange();
202
289
  }
203
290
  get(id) {
204
291
  return this.conns.get(id);
205
292
  }
206
- connected() {
207
- return [...this.conns.values()].filter((c) => c.state === "connected");
293
+ entries() {
294
+ return [...this.conns.values()];
208
295
  }
209
296
  list() {
210
297
  return [...this.conns.values()].map((c) => ({
@@ -213,13 +300,36 @@ var DownstreamManager = class {
213
300
  error: c.error,
214
301
  toolCount: c.capabilities.tools.length,
215
302
  resourceCount: c.capabilities.resources.length,
216
- promptCount: c.capabilities.prompts.length
303
+ promptCount: c.capabilities.prompts.length,
304
+ cached: c.hydrated
217
305
  }));
218
306
  }
219
307
  async closeAll() {
220
308
  await Promise.all([...this.conns.values()].map((c) => c.close()));
221
309
  this.conns.clear();
222
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
+ }
223
333
  };
224
334
 
225
335
  // src/aggregator/namespace.ts
@@ -252,38 +362,39 @@ var Aggregator = class {
252
362
  this.manager = manager;
253
363
  }
254
364
  manager;
365
+ // Advertise from every known connection's capabilities (cached while idle, live when
366
+ // connected). No connection is established just to list.
255
367
  async listTools() {
256
- return this.manager.connected().flatMap(
368
+ return this.manager.entries().flatMap(
257
369
  (c) => c.capabilities.tools.map((t) => ({ ...t, name: encodeName(c.def.id, t.name) }))
258
370
  );
259
371
  }
260
372
  async listPrompts() {
261
- return this.manager.connected().flatMap(
373
+ return this.manager.entries().flatMap(
262
374
  (c) => c.capabilities.prompts.map((p) => ({ ...p, name: encodeName(c.def.id, p.name) }))
263
375
  );
264
376
  }
265
377
  async listResources() {
266
- return this.manager.connected().flatMap(
378
+ return this.manager.entries().flatMap(
267
379
  (c) => c.capabilities.resources.map((r) => ({ ...r, uri: encodeUri(c.def.id, r.uri) }))
268
380
  );
269
381
  }
382
+ // Routing connects on demand via the connection's own methods (ensureConnected inside).
270
383
  async callTool(qualifiedName, args) {
271
384
  const { serverId, name } = decodeName(qualifiedName);
272
- return this.connOrThrow(serverId).client.callTool({ name, arguments: args });
385
+ return this.connOrThrow(serverId).callTool(name, args);
273
386
  }
274
387
  async getPrompt(qualifiedName, args) {
275
388
  const { serverId, name } = decodeName(qualifiedName);
276
- return this.connOrThrow(serverId).client.getPrompt({ name, arguments: args });
389
+ return this.connOrThrow(serverId).getPrompt(name, args);
277
390
  }
278
391
  async readResource(qualifiedUri) {
279
392
  const { serverId, uri } = decodeUri(qualifiedUri);
280
- return this.connOrThrow(serverId).client.readResource({ uri });
393
+ return this.connOrThrow(serverId).readResource(uri);
281
394
  }
282
395
  connOrThrow(serverId) {
283
396
  const conn = this.manager.get(serverId);
284
- if (!conn || conn.state !== "connected") {
285
- throw new Error(`downstream server '${serverId}' is not connected`);
286
- }
397
+ if (!conn) throw new Error(`unknown downstream server '${serverId}'`);
287
398
  return conn;
288
399
  }
289
400
  };
@@ -319,7 +430,7 @@ var MetaTools = class {
319
430
  },
320
431
  {
321
432
  name: "list_servers",
322
- 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.",
323
434
  inputSchema: { type: "object", properties: {} }
324
435
  }
325
436
  ];
@@ -369,7 +480,7 @@ import {
369
480
  } from "@modelcontextprotocol/sdk/types.js";
370
481
  function buildUpstreamServer(aggregator, meta) {
371
482
  const server = new Server(
372
- { name: "ai-conductor", version: "0.1.0" },
483
+ { name: "ai-conductor", version: VERSION },
373
484
  { capabilities: { tools: { listChanged: true }, resources: { listChanged: true }, prompts: { listChanged: true } } }
374
485
  );
375
486
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
@@ -391,6 +502,51 @@ function buildUpstreamServer(aggregator, meta) {
391
502
  return server;
392
503
  }
393
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
+
394
550
  // src/index.ts
395
551
  async function createConductor(opts = {}) {
396
552
  const store = opts.store ?? new ConfigStore();
@@ -403,7 +559,10 @@ async function createConductor(opts = {}) {
403
559
  server.sendResourceListChanged();
404
560
  server.sendPromptListChanged();
405
561
  };
406
- 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 });
407
566
  const aggregator = new Aggregator(manager);
408
567
  const meta = new MetaTools(manager, store);
409
568
  server = buildUpstreamServer(aggregator, meta);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-proxy-conductor",
3
- "version": "0.1.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
- "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": {