mcp-proxy-conductor 0.2.0 → 0.3.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.
Files changed (3) hide show
  1. package/README.md +32 -1
  2. package/dist/index.js +281 -8
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -89,6 +89,37 @@ Manage downstreams conversationally through the meta-tools:
89
89
 
90
90
  Its tools then appear as `filesystem__<toolname>`, callable immediately — no restart.
91
91
 
92
+ ## Discovering servers from registries
93
+
94
+ Instead of typing transport details by hand, discover servers from MCP registries and
95
+ install them through meta-tools:
96
+
97
+ - **`list_registries`** — enumerate available registries with capability flags
98
+ (`canConnect`, `canDetectSecrets`).
99
+ - **`search_registry(query, sources?)`** — search for servers. **Defaults to the official
100
+ registry** (`registry.modelcontextprotocol.io`); pass `sources` (ids from
101
+ `list_registries`) to widen the search.
102
+ - **`install_from_registry(ref, { id?, env?, secretBindings? })`** — install a result by
103
+ its `ref`. For entries that need secrets, store them with `secret set` and pass
104
+ `secretBindings: { ENV_NAME: "<keychain-name>" }`; for required non-secret env, pass
105
+ `env: { ENV_NAME: "<value>" }`.
106
+
107
+ Example: search, then install a server that needs a secret:
108
+
109
+ ```text
110
+ search_registry { "query": "filesystem" }
111
+ → official:com.example/fs (connectable, requiredSecrets: ["API_TOKEN"])
112
+
113
+ mcp-proxy-conductor secret set fs-token # in your terminal
114
+ install_from_registry { "ref": "official:com.example/fs", "secretBindings": { "API_TOKEN": "fs-token" } }
115
+ ```
116
+
117
+ **Sources & capability asymmetry:** the **official** registry provides structured run
118
+ details and secret flags, so auto-connect and secret detection work fully. **Glama** is a
119
+ discovery source (it lists servers but not a runnable command, so its entries are not
120
+ auto-connectable — use the results to then `add_server` manually). Per-source failures are
121
+ reported in the search result, never crashing the search.
122
+
92
123
  ## Credentials
93
124
 
94
125
  Secrets live in the **OS keychain** (macOS Keychain, Windows Credential Manager, Linux
@@ -157,7 +188,7 @@ Local, single-user focus. Known limitations:
157
188
  - Change notifications are coarse (all `listChanged` types emitted on any change).
158
189
  - HTTP downstreams do not auto-fall back to SSE; the transport type is explicit.
159
190
 
160
- Planned, not yet implemented: multi-tenant/SaaS mode and MCP registry lookup.
191
+ Planned, not yet implemented: multi-tenant/SaaS mode.
161
192
 
162
193
  ## License
163
194
 
package/dist/index.js CHANGED
@@ -200,7 +200,7 @@ import {
200
200
  } from "@modelcontextprotocol/sdk/types.js";
201
201
 
202
202
  // src/version.ts
203
- var VERSION = "0.2.0";
203
+ var VERSION = "0.3.0";
204
204
 
205
205
  // src/registry/connection.ts
206
206
  var EMPTY = { tools: [], resources: [], prompts: [] };
@@ -484,16 +484,64 @@ var Aggregator = class {
484
484
  }
485
485
  };
486
486
 
487
+ // src/registry-lookup/install.ts
488
+ function normalizeId(s) {
489
+ const cleaned = s.replace(/[^A-Za-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "");
490
+ return cleaned || "server";
491
+ }
492
+ function buildServerDefinition(entry, req) {
493
+ if (!entry.install) {
494
+ return {
495
+ guidance: `'${entry.ref}' has no run details from source '${entry.source}'. Add it manually with add_server (provide command/args or a url).`
496
+ };
497
+ }
498
+ const id = normalizeId(req.id ?? entry.name);
499
+ if (entry.install.type !== "stdio") {
500
+ return {
501
+ def: { id, enabled: true, transport: { type: entry.install.type, url: entry.install.url } }
502
+ };
503
+ }
504
+ const unboundSecrets = entry.requiredEnv.filter((e) => e.required && e.secret).filter((e) => !(req.secretBindings && e.name in req.secretBindings));
505
+ if (unboundSecrets.length > 0) {
506
+ const names = unboundSecrets.map((e) => e.name).join(", ");
507
+ const example = unboundSecrets[0].name;
508
+ return {
509
+ guidance: `'${entry.ref}' needs secret(s): ${names}. Store each with 'mcp-proxy-conductor secret set <name>', then re-run install_from_registry with secretBindings, e.g. { "${example}": "<keychain-name>" }.`
510
+ };
511
+ }
512
+ const missingEnv = entry.requiredEnv.filter((e) => e.required && !e.secret).filter((e) => !(req.env && e.name in req.env));
513
+ if (missingEnv.length > 0) {
514
+ const names = missingEnv.map((e) => e.name).join(", ");
515
+ return {
516
+ guidance: `'${entry.ref}' needs env value(s): ${names}. Re-run install_from_registry with env: { "${missingEnv[0].name}": "<value>" }.`
517
+ };
518
+ }
519
+ const env = { ...req.env ?? {} };
520
+ for (const [envName, keychainName] of Object.entries(req.secretBindings ?? {})) {
521
+ env[envName] = `\${keychain:${keychainName}}`;
522
+ }
523
+ return {
524
+ def: {
525
+ id,
526
+ enabled: true,
527
+ transport: { type: "stdio", command: entry.install.command, args: entry.install.args, env }
528
+ }
529
+ };
530
+ }
531
+
487
532
  // src/meta/tools.ts
488
533
  var ok = (text) => ({ content: [{ type: "text", text }] });
489
534
  var fail = (text) => ({ content: [{ type: "text", text }], isError: true });
490
535
  var MetaTools = class {
491
- constructor(manager, store) {
536
+ constructor(manager, store, registry) {
492
537
  this.manager = manager;
493
538
  this.store = store;
539
+ this.registry = registry;
494
540
  }
495
541
  manager;
496
542
  store;
543
+ registry;
544
+ lastEntries = /* @__PURE__ */ new Map();
497
545
  definitions() {
498
546
  return [
499
547
  {
@@ -517,30 +565,67 @@ var MetaTools = class {
517
565
  name: "list_servers",
518
566
  description: "List managed downstream servers: connection state (idle/connected/error), whether capabilities are served from cache, and capability counts.",
519
567
  inputSchema: { type: "object", properties: {} }
568
+ },
569
+ {
570
+ name: "list_registries",
571
+ description: "List available MCP registries to search, with capability flags (canConnect, canDetectSecrets).",
572
+ inputSchema: { type: "object", properties: {} }
573
+ },
574
+ {
575
+ name: "search_registry",
576
+ description: "Search MCP registries for servers. Defaults to the official registry; pass sources (from list_registries) to include others.",
577
+ inputSchema: {
578
+ type: "object",
579
+ properties: {
580
+ query: { type: "string" },
581
+ sources: { type: "array", items: { type: "string" }, description: 'Registry ids to search; default ["official"]' },
582
+ limit: { type: "number", description: "Max results per source (default 10)" }
583
+ },
584
+ required: ["query"]
585
+ }
586
+ },
587
+ {
588
+ name: "install_from_registry",
589
+ description: "Install a server found via search_registry by its ref. Provide secretBindings { ENV_NAME: keychain-name } for required secrets, and env { ENV_NAME: value } for required non-secret env.",
590
+ inputSchema: {
591
+ type: "object",
592
+ properties: {
593
+ ref: { type: "string", description: "The ref from a search_registry result" },
594
+ id: { type: "string", description: "Optional downstream id (defaults to a normalized name)" },
595
+ env: { type: "object" },
596
+ secretBindings: { type: "object" }
597
+ },
598
+ required: ["ref"]
599
+ }
520
600
  }
521
601
  ];
522
602
  }
523
603
  has(name) {
524
- return ["add_server", "remove_server", "list_servers"].includes(name);
604
+ return ["add_server", "remove_server", "list_servers", "list_registries", "search_registry", "install_from_registry"].includes(name);
525
605
  }
526
606
  async call(name, args) {
527
607
  try {
528
608
  if (name === "add_server") return await this.addServer(args);
529
609
  if (name === "remove_server") return await this.removeServer(args);
530
610
  if (name === "list_servers") return ok(JSON.stringify(this.manager.list(), null, 2));
611
+ if (name === "list_registries") return this.listRegistries();
612
+ if (name === "search_registry") return await this.searchRegistry(args);
613
+ if (name === "install_from_registry") return await this.installFromRegistry(args);
531
614
  return fail(`unknown meta-tool: ${name}`);
532
615
  } catch (err) {
533
616
  return fail(err instanceof Error ? err.message : String(err));
534
617
  }
535
618
  }
536
- async addServer(args) {
537
- const def = ServerDefinitionSchema.parse({ id: args.id, transport: args.transport, enabled: true });
619
+ async persistAndAdd(def) {
538
620
  await this.manager.add(def);
539
621
  const config = await this.store.load();
540
622
  config.servers = [...config.servers.filter((s) => s.id !== def.id), def];
541
623
  await this.store.save(config);
542
- const state = this.manager.get(def.id)?.state;
543
- return ok(`server '${def.id}' added (state: ${state}).`);
624
+ }
625
+ async addServer(args) {
626
+ const def = ServerDefinitionSchema.parse({ id: args.id, transport: args.transport, enabled: true });
627
+ await this.persistAndAdd(def);
628
+ return ok(`server '${def.id}' added (state: ${this.manager.get(def.id)?.state}).`);
544
629
  }
545
630
  async removeServer(args) {
546
631
  const id = String(args.id ?? "");
@@ -551,6 +636,46 @@ var MetaTools = class {
551
636
  await this.store.save(config);
552
637
  return ok(`server '${id}' removed.`);
553
638
  }
639
+ listRegistries() {
640
+ if (!this.registry) return fail("registry lookup is not configured");
641
+ return ok(JSON.stringify(this.registry.list(), null, 2));
642
+ }
643
+ async searchRegistry(args) {
644
+ if (!this.registry) return fail("registry lookup is not configured");
645
+ const query = String(args.query ?? "");
646
+ if (!query) return fail("query is required");
647
+ const sources = Array.isArray(args.sources) ? args.sources : void 0;
648
+ const limit = typeof args.limit === "number" ? args.limit : 10;
649
+ const outcome = await this.registry.search(query, sources, limit);
650
+ for (const e of outcome.entries) this.lastEntries.set(e.ref, e);
651
+ const view = outcome.entries.map((e) => ({
652
+ ref: e.ref,
653
+ source: e.source,
654
+ name: e.name,
655
+ description: e.description,
656
+ connectable: e.install !== void 0,
657
+ requiredSecrets: e.requiredEnv.filter((v) => v.secret).map((v) => v.name)
658
+ }));
659
+ const payload = { results: view };
660
+ if (outcome.errors.length > 0) payload.errors = outcome.errors;
661
+ return ok(JSON.stringify(payload, null, 2));
662
+ }
663
+ async installFromRegistry(args) {
664
+ if (!this.registry) return fail("registry lookup is not configured");
665
+ const ref = String(args.ref ?? "");
666
+ const entry = this.lastEntries.get(ref);
667
+ if (!entry) return fail(`unknown ref '${ref}'. Run search_registry first and use a ref from its results.`);
668
+ const plan = buildServerDefinition(entry, {
669
+ id: args.id ? String(args.id) : void 0,
670
+ env: args.env ?? void 0,
671
+ secretBindings: args.secretBindings ?? void 0
672
+ });
673
+ if (!plan.def) return ok(plan.guidance ?? "cannot install this entry automatically.");
674
+ const def = ServerDefinitionSchema.parse(plan.def);
675
+ if (this.manager.get(def.id)) return fail(`server '${def.id}' already exists`);
676
+ await this.persistAndAdd(def);
677
+ return ok(`installed '${def.id}' from ${entry.source} (state: ${this.manager.get(def.id)?.state}).`);
678
+ }
554
679
  };
555
680
 
556
681
  // src/server/upstream.ts
@@ -632,6 +757,153 @@ function tryJson(raw) {
632
757
  }
633
758
  }
634
759
 
760
+ // src/registry-lookup/aggregate.ts
761
+ var RegistryAggregator = class {
762
+ constructor(sources, timeoutMs = 8e3) {
763
+ this.timeoutMs = timeoutMs;
764
+ this.sources = new Map(sources.map((s) => [s.id, s]));
765
+ }
766
+ timeoutMs;
767
+ sources;
768
+ list() {
769
+ return [...this.sources.values()].map((s) => ({
770
+ id: s.id,
771
+ title: s.title,
772
+ canConnect: s.capabilities.canConnect,
773
+ canDetectSecrets: s.capabilities.canDetectSecrets
774
+ }));
775
+ }
776
+ async search(query, sourceIds = ["official"], limit = 10) {
777
+ for (const id of sourceIds) {
778
+ if (!this.sources.has(id)) throw new Error(`unknown registry '${id}' (call list_registries to see available sources)`);
779
+ }
780
+ const results = await Promise.all(
781
+ sourceIds.map(async (id) => {
782
+ const src = this.sources.get(id);
783
+ try {
784
+ const entries = await this.withTimeout(src.search(query, limit), id);
785
+ return { entries, error: void 0 };
786
+ } catch (err) {
787
+ return { entries: [], error: { source: id, error: err instanceof Error ? err.message : String(err) } };
788
+ }
789
+ })
790
+ );
791
+ return {
792
+ entries: results.flatMap((r) => r.entries),
793
+ errors: results.flatMap((r) => r.error ? [r.error] : [])
794
+ };
795
+ }
796
+ withTimeout(p, id) {
797
+ return new Promise((resolve, reject) => {
798
+ const timer = setTimeout(() => reject(new Error(`${id} timed out after ${this.timeoutMs}ms`)), this.timeoutMs);
799
+ timer.unref?.();
800
+ p.then((v) => {
801
+ clearTimeout(timer);
802
+ resolve(v);
803
+ }, (e) => {
804
+ clearTimeout(timer);
805
+ reject(e);
806
+ });
807
+ });
808
+ }
809
+ };
810
+
811
+ // src/registry-lookup/official.ts
812
+ var OfficialRegistry = class {
813
+ constructor(fetchImpl = globalThis.fetch, baseUrl = "https://registry.modelcontextprotocol.io") {
814
+ this.fetchImpl = fetchImpl;
815
+ this.baseUrl = baseUrl;
816
+ }
817
+ fetchImpl;
818
+ baseUrl;
819
+ id = "official";
820
+ title = "Official MCP Registry";
821
+ capabilities = { canConnect: true, canDetectSecrets: true };
822
+ async search(query, limit) {
823
+ const url = `${this.baseUrl}/v0/servers?search=${encodeURIComponent(query)}&limit=${limit}`;
824
+ const res = await this.fetchImpl(url);
825
+ if (!res.ok) throw new Error(`official registry returned HTTP ${res.status}`);
826
+ const body = await res.json();
827
+ return (body.servers ?? []).map((s) => this.toEntry(s.server));
828
+ }
829
+ // Prefer a remote (no child process); else the first npm package as stdio. install and
830
+ // requiredEnv are derived from the SAME chosen package so they never describe different
831
+ // packages. requiredEnv is only populated for a stdio install (a remote's auth is not
832
+ // described by package env vars).
833
+ toEntry(server) {
834
+ const remote = server.remotes?.[0];
835
+ const pkg = (server.packages ?? []).find((p) => p.registryType === "npm") ?? server.packages?.[0];
836
+ let install;
837
+ let requiredEnv = [];
838
+ if (remote?.url) {
839
+ install = { type: remote.type === "sse" ? "sse" : "http", url: remote.url };
840
+ } else if (pkg?.identifier) {
841
+ install = {
842
+ type: "stdio",
843
+ command: pkg.runtimeHint || "npx",
844
+ args: ["-y", pkg.identifier],
845
+ envNames: (pkg.environmentVariables ?? []).map((e) => e.name)
846
+ };
847
+ requiredEnv = (pkg.environmentVariables ?? []).map((e) => ({
848
+ name: e.name,
849
+ description: e.description,
850
+ required: !!e.isRequired,
851
+ secret: !!e.isSecret
852
+ }));
853
+ }
854
+ return {
855
+ source: this.id,
856
+ ref: `${this.id}:${server.name}`,
857
+ name: server.name,
858
+ description: server.description ?? "",
859
+ install,
860
+ requiredEnv
861
+ };
862
+ }
863
+ };
864
+
865
+ // src/registry-lookup/glama.ts
866
+ var GlamaRegistry = class {
867
+ constructor(fetchImpl = globalThis.fetch, baseUrl = "https://glama.ai") {
868
+ this.fetchImpl = fetchImpl;
869
+ this.baseUrl = baseUrl;
870
+ }
871
+ fetchImpl;
872
+ baseUrl;
873
+ id = "glama";
874
+ title = "Glama";
875
+ // Glama lists repository + an env schema but no runnable command and no isSecret flag,
876
+ // so it is a discovery-only source (no auto-connect, no secret detection).
877
+ capabilities = { canConnect: false, canDetectSecrets: false };
878
+ async search(query, limit) {
879
+ const url = `${this.baseUrl}/api/mcp/v1/servers?query=${encodeURIComponent(query)}&first=${limit}`;
880
+ const res = await this.fetchImpl(url);
881
+ if (!res.ok) throw new Error(`glama registry returned HTTP ${res.status}`);
882
+ const body = await res.json();
883
+ return (body.servers ?? []).map((s) => this.toEntry(s));
884
+ }
885
+ toEntry(s) {
886
+ return {
887
+ source: this.id,
888
+ ref: `${this.id}:${s.id}`,
889
+ name: s.name,
890
+ description: s.description ?? "",
891
+ install: void 0,
892
+ requiredEnv: this.envOf(s.environmentVariablesJsonSchema)
893
+ };
894
+ }
895
+ envOf(schema) {
896
+ const required = new Set(schema?.required ?? []);
897
+ return Object.entries(schema?.properties ?? {}).map(([name, p]) => ({
898
+ name,
899
+ description: p.description,
900
+ required: required.has(name),
901
+ secret: false
902
+ // Glama does not flag secrets
903
+ }));
904
+ }
905
+ };
906
+
635
907
  // src/index.ts
636
908
  async function createConductor(opts = {}) {
637
909
  const store = opts.store ?? new ConfigStore();
@@ -649,7 +921,8 @@ async function createConductor(opts = {}) {
649
921
  const idleMs = opts.idleMs ?? (Number.isFinite(rawIdleMs) ? rawIdleMs : 3e5);
650
922
  const manager = new DownstreamManager(transportFactory, { onChange, cache, idleMs });
651
923
  const aggregator = new Aggregator(manager);
652
- const meta = new MetaTools(manager, store);
924
+ const registry = opts.registry ?? new RegistryAggregator([new OfficialRegistry(), new GlamaRegistry()]);
925
+ const meta = new MetaTools(manager, store, registry);
653
926
  server = buildUpstreamServer(aggregator, meta);
654
927
  const config = await store.load();
655
928
  await manager.start(config.servers);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-proxy-conductor",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
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",