mcp-proxy-conductor 0.1.4 → 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.
- package/README.md +32 -1
- package/dist/index.js +281 -8
- 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
|
|
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.
|
|
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
|
|
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
|
-
|
|
543
|
-
|
|
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
|
|
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