mcp-proxy-conductor 0.1.3 → 0.1.4

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 +63 -21
  2. package/dist/index.js +115 -19
  3. 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
- - **Persistence** added servers are stored and reconnected on the next start.
35
- - Auth passed through per downstream (env vars for stdio, headers for HTTP/SSE), behind a
36
- pluggable `SecretProvider` interface.
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
- ## Usage
63
+ ## Managing downstream servers
63
64
 
64
- Manage downstream servers conversationally through the meta-tools:
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
- - `id`: the server id.
74
- - **`list_servers`** list managed servers with connection state and capability counts.
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
- Once connected, its tools appear as `filesystem__<toolname>` and are callable
92
- immediately — no restart.
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
- ### Secrets
134
+ ## Lazy connections
95
135
 
96
- `env` and `headers` values support `${VAR}` references that are expanded from the
97
- environment at connect time, so you can avoid hardcoding tokens, e.g.
98
- `"Authorization": "Bearer ${UPTIMEROBOT_TOKEN}"`.
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
- This is an MVP focused on the local, single-user case. Known limitations:
153
+ Local, single-user focus. Known limitations:
112
154
 
113
- - No automatic reconnect/backoff for a downstream that crashes (remove + add to recover).
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, MCP registry lookup, OS-keychain
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 = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
74
- var EnvSecretProvider = class {
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, (_, name) => {
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.1.3";
203
+ var VERSION = "0.1.4";
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 EnvSecretProvider();
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
- createConductor().then(async (c) => {
605
- installShutdownHandlers(c);
606
- await c.start();
607
- }).catch((err) => {
608
- console.error("ai-conductor failed to start:", err);
609
- process.exit(1);
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.1.3",
3
+ "version": "0.1.4",
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
  },