mcp-proxy-conductor 0.1.3 → 0.2.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 +63 -21
- package/dist/index.js +115 -19
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -31,9 +31,10 @@ notifications.
|
|
|
31
31
|
- Downstream transports: **stdio**, **Streamable HTTP**, **SSE**.
|
|
32
32
|
- Aggregates **tools, resources, and prompts** from all downstreams, with per-server
|
|
33
33
|
namespacing (`serverId__name`) so names never collide.
|
|
34
|
-
- **
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
- **Lazy connections**: downstreams connect on first use and disconnect after an idle
|
|
35
|
+
timeout; capabilities are served from a cache so tools are advertised without connecting.
|
|
36
|
+
- **Credentials in the OS keychain** — referenced from config, never stored in plaintext.
|
|
37
|
+
- **Persistence** — added servers are stored and reconnected (lazily) on the next start.
|
|
37
38
|
|
|
38
39
|
## Requirements
|
|
39
40
|
|
|
@@ -59,24 +60,22 @@ Or add it manually to your client's MCP config:
|
|
|
59
60
|
|
|
60
61
|
Restart the client once. ai-conductor then exposes its meta-tools.
|
|
61
62
|
|
|
62
|
-
##
|
|
63
|
+
## Managing downstream servers
|
|
63
64
|
|
|
64
|
-
Manage
|
|
65
|
+
Manage downstreams conversationally through the meta-tools:
|
|
65
66
|
|
|
66
67
|
- **`add_server`** — add and connect a downstream server at runtime (persists).
|
|
67
|
-
- `id`: unique, alphanumeric/hyphen, no underscores
|
|
68
|
+
- `id`: unique, alphanumeric/hyphen, **no underscores**.
|
|
68
69
|
- `transport`: one of
|
|
69
70
|
- `{ "type": "stdio", "command": "...", "args": [...], "env": { ... } }`
|
|
70
71
|
- `{ "type": "http", "url": "https://…/mcp", "headers": { ... } }`
|
|
71
72
|
- `{ "type": "sse", "url": "https://…/sse", "headers": { ... } }`
|
|
72
|
-
- **`remove_server`** — disconnect and remove a downstream server (persists).
|
|
73
|
-
|
|
74
|
-
|
|
73
|
+
- **`remove_server`** — disconnect and remove a downstream server (persists). Arg: `id`.
|
|
74
|
+
- **`list_servers`** — list managed servers with connection state (`idle`/`connected`/
|
|
75
|
+
`error`), whether capabilities come from cache, and capability counts.
|
|
75
76
|
|
|
76
77
|
### Example
|
|
77
78
|
|
|
78
|
-
Adding an stdio-based downstream:
|
|
79
|
-
|
|
80
79
|
```json
|
|
81
80
|
{
|
|
82
81
|
"id": "filesystem",
|
|
@@ -88,14 +87,57 @@ Adding an stdio-based downstream:
|
|
|
88
87
|
}
|
|
89
88
|
```
|
|
90
89
|
|
|
91
|
-
|
|
92
|
-
|
|
90
|
+
Its tools then appear as `filesystem__<toolname>`, callable immediately — no restart.
|
|
91
|
+
|
|
92
|
+
## Credentials
|
|
93
|
+
|
|
94
|
+
Secrets live in the **OS keychain** (macOS Keychain, Windows Credential Manager, Linux
|
|
95
|
+
Secret Service) — never in `config.json` and never passed through the chat.
|
|
96
|
+
|
|
97
|
+
**1. Store a secret** with the CLI (the value is read from hidden stdin, not the chat):
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
mcp-proxy-conductor secret set my-token
|
|
101
|
+
# or pipe it (e.g. in scripts):
|
|
102
|
+
printf '%s' "$MY_TOKEN" | mcp-proxy-conductor secret set my-token
|
|
103
|
+
|
|
104
|
+
# remove it again:
|
|
105
|
+
mcp-proxy-conductor secret rm my-token
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
**2. Reference it** in `add_server` via `${keychain:<name>}`:
|
|
109
|
+
|
|
110
|
+
```json
|
|
111
|
+
{
|
|
112
|
+
"id": "uptime",
|
|
113
|
+
"transport": {
|
|
114
|
+
"type": "http",
|
|
115
|
+
"url": "https://example.com/mcp",
|
|
116
|
+
"headers": { "Authorization": "Bearer ${keychain:my-token}" }
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
References are resolved at connect time. Supported placeholder schemes (usable in any
|
|
122
|
+
`env` or `headers` value, and combinable within a string):
|
|
123
|
+
|
|
124
|
+
| Reference | Resolves from |
|
|
125
|
+
|---|---|
|
|
126
|
+
| `${keychain:NAME}` | OS keychain (service `mcp-proxy-conductor`) |
|
|
127
|
+
| `${env:VAR}` | process environment |
|
|
128
|
+
| `${VAR}` | process environment (shorthand) |
|
|
129
|
+
|
|
130
|
+
> **Headless Linux** without a running Secret Service daemon cannot use the keychain —
|
|
131
|
+
> use `${env:…}` there instead. A missing or unreachable secret surfaces as an `error`
|
|
132
|
+
> state for that one downstream (visible in `list_servers`); other servers keep working.
|
|
93
133
|
|
|
94
|
-
|
|
134
|
+
## Lazy connections
|
|
95
135
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
136
|
+
To scale to many downstreams, connections are **late-bound**: at startup nothing is
|
|
137
|
+
connected — tools/resources/prompts are advertised from a persisted capability cache. A
|
|
138
|
+
downstream connects on the **first actual call** and is evicted after an idle timeout
|
|
139
|
+
(default **5 minutes**, override with `CONDUCTOR_IDLE_TIMEOUT_MS`, in ms; `0` disables
|
|
140
|
+
eviction). The first call to an idle server therefore pays a one-time connect latency.
|
|
99
141
|
|
|
100
142
|
## Development
|
|
101
143
|
|
|
@@ -108,14 +150,14 @@ pnpm build # tsup → dist/index.js
|
|
|
108
150
|
|
|
109
151
|
## Status & limitations
|
|
110
152
|
|
|
111
|
-
|
|
153
|
+
Local, single-user focus. Known limitations:
|
|
112
154
|
|
|
113
|
-
- No automatic reconnect/backoff for a downstream that crashes (
|
|
155
|
+
- No automatic reconnect/backoff for a downstream that crashes (it returns to `idle` and
|
|
156
|
+
reconnects on the next call).
|
|
114
157
|
- Change notifications are coarse (all `listChanged` types emitted on any change).
|
|
115
158
|
- HTTP downstreams do not auto-fall back to SSE; the transport type is explicit.
|
|
116
159
|
|
|
117
|
-
Planned, not yet implemented: multi-tenant/SaaS mode
|
|
118
|
-
secret backend.
|
|
160
|
+
Planned, not yet implemented: multi-tenant/SaaS mode and MCP registry lookup.
|
|
119
161
|
|
|
120
162
|
## License
|
|
121
163
|
|
package/dist/index.js
CHANGED
|
@@ -70,26 +70,111 @@ var ConfigStore = class {
|
|
|
70
70
|
};
|
|
71
71
|
|
|
72
72
|
// src/secrets/provider.ts
|
|
73
|
-
var REF = /\$\{([
|
|
74
|
-
var
|
|
75
|
-
constructor(env = process.env) {
|
|
76
|
-
this.env = env;
|
|
77
|
-
}
|
|
73
|
+
var REF = /\$\{([^}]+)\}/g;
|
|
74
|
+
var SecretResolver = class {
|
|
78
75
|
env;
|
|
76
|
+
keychain;
|
|
77
|
+
constructor(opts = {}) {
|
|
78
|
+
this.env = opts.env ?? process.env;
|
|
79
|
+
this.keychain = opts.keychain;
|
|
80
|
+
}
|
|
79
81
|
async resolve(value) {
|
|
80
|
-
return value.replace(REF, (_,
|
|
81
|
-
const v = this.env[name];
|
|
82
|
-
if (v === void 0) throw new Error(`Secret reference \${${name}} is not set in the environment`);
|
|
83
|
-
return v;
|
|
84
|
-
});
|
|
82
|
+
return value.replace(REF, (_, token) => this.lookup(token));
|
|
85
83
|
}
|
|
86
84
|
async resolveRecord(record) {
|
|
87
85
|
const out = {};
|
|
88
86
|
for (const [k, v] of Object.entries(record)) out[k] = await this.resolve(v);
|
|
89
87
|
return out;
|
|
90
88
|
}
|
|
89
|
+
// Resolve a single placeholder token (the text between ${ and }).
|
|
90
|
+
lookup(token) {
|
|
91
|
+
const idx = token.indexOf(":");
|
|
92
|
+
const scheme = idx === -1 ? "env" : token.slice(0, idx);
|
|
93
|
+
const name = idx === -1 ? token : token.slice(idx + 1);
|
|
94
|
+
if (scheme === "env") {
|
|
95
|
+
const v = this.env[name];
|
|
96
|
+
if (v === void 0) throw new Error(`Secret reference \${${token}} is not set in the environment`);
|
|
97
|
+
return v;
|
|
98
|
+
}
|
|
99
|
+
if (scheme === "keychain") {
|
|
100
|
+
if (!this.keychain) throw new Error(`Cannot resolve \${${token}}: no keychain backend configured`);
|
|
101
|
+
const v = this.keychain.get(name);
|
|
102
|
+
if (v === null) {
|
|
103
|
+
throw new Error(`Secret '${name}' not found in the OS keychain (store it with: mcp-proxy-conductor secret set ${name})`);
|
|
104
|
+
}
|
|
105
|
+
return v;
|
|
106
|
+
}
|
|
107
|
+
throw new Error(`Unknown secret scheme '${scheme}' in \${${token}}`);
|
|
108
|
+
}
|
|
91
109
|
};
|
|
92
110
|
|
|
111
|
+
// src/secrets/keychain.ts
|
|
112
|
+
import { Entry } from "@napi-rs/keyring";
|
|
113
|
+
var KEYCHAIN_SERVICE = "mcp-proxy-conductor";
|
|
114
|
+
function osKeychain(service = KEYCHAIN_SERVICE) {
|
|
115
|
+
return {
|
|
116
|
+
get: (name) => new Entry(service, name).getPassword(),
|
|
117
|
+
set: (name, value) => {
|
|
118
|
+
new Entry(service, name).setPassword(value);
|
|
119
|
+
},
|
|
120
|
+
delete: (name) => new Entry(service, name).deletePassword()
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// src/cli/secret.ts
|
|
125
|
+
import { createInterface } from "readline";
|
|
126
|
+
var USAGE = "usage: mcp-proxy-conductor secret <set|rm> <name>";
|
|
127
|
+
async function runSecretCommand(args, backend, io) {
|
|
128
|
+
const [sub, name] = args;
|
|
129
|
+
if (sub === "set") {
|
|
130
|
+
if (!name) {
|
|
131
|
+
io.out(USAGE);
|
|
132
|
+
return 1;
|
|
133
|
+
}
|
|
134
|
+
const value = await io.readSecret();
|
|
135
|
+
if (!value) {
|
|
136
|
+
io.out("aborted: empty value, nothing stored");
|
|
137
|
+
return 1;
|
|
138
|
+
}
|
|
139
|
+
backend.set(name, value);
|
|
140
|
+
io.out(`stored secret '${name}' in the OS keychain.`);
|
|
141
|
+
return 0;
|
|
142
|
+
}
|
|
143
|
+
if (sub === "rm") {
|
|
144
|
+
if (!name) {
|
|
145
|
+
io.out(USAGE);
|
|
146
|
+
return 1;
|
|
147
|
+
}
|
|
148
|
+
const existed = backend.delete(name);
|
|
149
|
+
io.out(existed ? `removed secret '${name}'.` : `no secret '${name}' found.`);
|
|
150
|
+
return 0;
|
|
151
|
+
}
|
|
152
|
+
io.out(USAGE);
|
|
153
|
+
return 1;
|
|
154
|
+
}
|
|
155
|
+
function promptHidden(promptText) {
|
|
156
|
+
return new Promise((resolve) => {
|
|
157
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
|
|
158
|
+
let promptWritten = false;
|
|
159
|
+
rl._writeToOutput = () => {
|
|
160
|
+
if (!promptWritten) {
|
|
161
|
+
process.stdout.write(promptText);
|
|
162
|
+
promptWritten = true;
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
rl.question(promptText, (answer) => {
|
|
166
|
+
rl.close();
|
|
167
|
+
process.stdout.write("\n");
|
|
168
|
+
resolve(answer);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
async function readAllStdin() {
|
|
173
|
+
const chunks = [];
|
|
174
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
175
|
+
return Buffer.concat(chunks).toString("utf8").replace(/\r?\n$/, "");
|
|
176
|
+
}
|
|
177
|
+
|
|
93
178
|
// src/registry/transport.ts
|
|
94
179
|
import { StdioClientTransport, getDefaultEnvironment } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
95
180
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
@@ -115,7 +200,7 @@ import {
|
|
|
115
200
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
116
201
|
|
|
117
202
|
// src/version.ts
|
|
118
|
-
var VERSION = "0.
|
|
203
|
+
var VERSION = "0.2.0";
|
|
119
204
|
|
|
120
205
|
// src/registry/connection.ts
|
|
121
206
|
var EMPTY = { tools: [], resources: [], prompts: [] };
|
|
@@ -550,7 +635,7 @@ function tryJson(raw) {
|
|
|
550
635
|
// src/index.ts
|
|
551
636
|
async function createConductor(opts = {}) {
|
|
552
637
|
const store = opts.store ?? new ConfigStore();
|
|
553
|
-
const secrets = new
|
|
638
|
+
const secrets = opts.secrets ?? new SecretResolver({ env: process.env, keychain: osKeychain() });
|
|
554
639
|
const transportFactory = opts.transportFactory ?? ((def) => buildTransport(def, secrets));
|
|
555
640
|
let server;
|
|
556
641
|
const onChange = () => {
|
|
@@ -601,13 +686,24 @@ function isMainModule(argv1, importMetaUrl) {
|
|
|
601
686
|
}
|
|
602
687
|
}
|
|
603
688
|
if (isMainModule(process.argv[1], import.meta.url)) {
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
process.exit(
|
|
610
|
-
|
|
689
|
+
const argv = process.argv.slice(2);
|
|
690
|
+
if (argv[0] === "secret") {
|
|
691
|
+
runSecretCommand(argv.slice(1), osKeychain(), {
|
|
692
|
+
readSecret: () => process.stdin.isTTY ? promptHidden("Secret value: ") : readAllStdin(),
|
|
693
|
+
out: (m) => console.log(m)
|
|
694
|
+
}).then((code) => process.exit(code)).catch((err) => {
|
|
695
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
696
|
+
process.exit(1);
|
|
697
|
+
});
|
|
698
|
+
} else {
|
|
699
|
+
createConductor().then(async (c) => {
|
|
700
|
+
installShutdownHandlers(c);
|
|
701
|
+
await c.start();
|
|
702
|
+
}).catch((err) => {
|
|
703
|
+
console.error("ai-conductor failed to start:", err);
|
|
704
|
+
process.exit(1);
|
|
705
|
+
});
|
|
706
|
+
}
|
|
611
707
|
}
|
|
612
708
|
export {
|
|
613
709
|
createConductor,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-proxy-conductor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.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",
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
44
|
"@modelcontextprotocol/sdk": "1.29.0",
|
|
45
|
+
"@napi-rs/keyring": "^1.3.0",
|
|
45
46
|
"env-paths": "^3.0.0",
|
|
46
47
|
"zod": "^3.23.8"
|
|
47
48
|
},
|