@lifcc/mcp-mux 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 majiayu000
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,196 @@
1
- # Temporary Holding Version
1
+ # mcp-mux: multiple accounts for any MCP server
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![CI](https://github.com/majiayu000/mcp-mux/actions/workflows/ci.yml/badge.svg)](https://github.com/majiayu000/mcp-mux/actions/workflows/ci.yml)
4
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
5
+ [![Runtime: Bun](https://img.shields.io/badge/runtime-bun%20%E2%89%A5%201.3-f9f1e1?logo=bun)](https://bun.sh)
6
+ [![MCP](https://img.shields.io/badge/MCP-stdio%20%7C%20streamable%20HTTP-6E56CF)](https://modelcontextprotocol.io)
7
+
8
+ **mcp-mux lets Claude Code and Codex use multiple accounts of the same MCP server at once: two Gmail inboxes, a work and a client Slack, several Notion or Linear workspaces.** It runs as one local MCP server. Every tool gets a required `account` argument, each account signs in with its own OAuth session, and tokens are kept in the macOS Keychain.
9
+
10
+ ![mcp-mux: one MCP server, many accounts](docs/social-preview.png)
11
+
12
+ Today, Claude connectors take one account per connector ([anthropics/claude-code#27302](https://github.com/anthropics/claude-code/issues/27302), 422 👍, no official answer). Slack takes one workspace ([#44243](https://github.com/anthropics/claude-code/issues/44243)). Codex added multiple accounts only for Gmail, Calendar and Contacts ([openai/codex#20500](https://github.com/openai/codex/issues/20500)). mcp-mux works with any MCP server you can run or reach locally.
13
+
14
+ ## Quick start
15
+
16
+ Requires Bun ≥ 1.3. Install the scoped npm package:
17
+
18
+ ```sh
19
+ npm install -g @lifcc/mcp-mux@0.1.0
20
+ mcp-mux --help
21
+ ```
22
+
23
+ The package is named `@lifcc/mcp-mux`; the executable remains `mcp-mux`.
24
+
25
+ Or install dependencies in a source checkout:
26
+
27
+ ```sh
28
+ git clone https://github.com/majiayu000/mcp-mux.git
29
+ cd mcp-mux
30
+ bun install --frozen-lockfile
31
+ ```
32
+
33
+ The commands below run from this checkout. For client configuration, use absolute paths to Bun, `src/cli.ts`, and your config file.
34
+
35
+ Write `notion.mux.json`. Accounts share the upstream and override only what differs:
36
+
37
+ ```json
38
+ {
39
+ "name": "notion",
40
+ "upstream": { "url": "https://mcp.notion.com/mcp", "oauth": {} },
41
+ "accounts": {
42
+ "work": { "description": "Acme workspace", "oauth": { "authorizeParams": { "login_hint": "me@acme.com" } } },
43
+ "personal": { "description": "personal notes" }
44
+ }
45
+ }
46
+ ```
47
+
48
+ Sign in each account once, check them, and add the server to your client:
49
+
50
+ ```sh
51
+ bun src/cli.ts login notion.mux.json work
52
+ bun src/cli.ts login notion.mux.json personal
53
+ bun src/cli.ts status notion.mux.json
54
+
55
+ claude mcp add notion -- /absolute/path/to/bun /absolute/path/to/mcp-mux/src/cli.ts serve /abs/path/notion.mux.json # Claude Code
56
+ codex mcp add notion -- /absolute/path/to/bun /absolute/path/to/mcp-mux/src/cli.ts serve /abs/path/notion.mux.json # Codex
57
+ ```
58
+
59
+ The model now sees each Notion tool once, with `account: "work" | "personal"` required.
60
+
61
+ ## What it looks like
62
+
63
+ This run uses the bundled mock OAuth server (`examples/mock-oauth-server.ts`): two fake users behind one URL, told apart only by their tokens, the same way Gmail or Notion work.
64
+
65
+ ```text
66
+ $ mcp-mux login mail.mux.json work
67
+ [mcp-mux] logged in "work" (2 tools available)
68
+ $ mcp-mux login mail.mux.json home
69
+ [mcp-mux] logged in "home" (2 tools available)
70
+ $ mcp-mux status mail.mux.json
71
+ work ok (2 tools) http: http://127.0.0.1:47821/mcp
72
+ home ok (2 tools) http: http://127.0.0.1:47821/mcp
73
+
74
+ # what the client sees after `mcp-mux serve mail.mux.json`
75
+ tools: whoami, search_mail
76
+ account: {"type":"string","enum":["work","home"],"description":"Which account to use: work (alice@work.example), home (alice@home.example)"}
77
+ whoami({"account":"work"}) -> alice@work.example
78
+ whoami({"account":"home"}) -> alice@home.example
79
+ search_mail({"account":"work","query":"e"}) -> ["Q3 budget review moved to Friday","Invoice #4411 from Acme"]
80
+ search_mail({"account":"home","query":"e"}) -> ["Dentist appointment reminder","Your library books are due"]
81
+ whoami({"account":"nope"}) -> error -32602: unknown account "nope"; expected one of: work, home
82
+ ```
83
+
84
+ The same flow ran in Claude Code: `claude mcp list` reported `✔ Connected` for both a filesystem mux and this OAuth mux.
85
+
86
+ ## Features
87
+
88
+ - **One toolset, many accounts.** The client lists each upstream tool once, with a required `account` enum. Optional `description` labels (an email address, a workspace name) are shown to the model next to each account.
89
+ - **Fails closed.** A missing or unknown account is an error. A logged-out account returns an error naming the `mcp-mux login` command to run. mcp-mux never falls back to another account or returns an empty result in its place.
90
+ - **Per-account OAuth 2.1** for remote servers: protected resource discovery, dynamic client registration or a pre-registered client, PKCE, RFC 8707 `resource`, RFC 9207 `iss` check, and automatic refresh. `authorizeParams` pins a login to one identity, for example Google `login_hint` or Slack `team`.
91
+ - **Isolated credentials.** Each account gets its own Keychain entry (`mcp-mux` / `<name>/<account>`), or an AES-256-GCM encrypted file on other platforms. A stdio account sees only its own environment variables, never another account's secrets.
92
+ - **Self-healing.** If an upstream crashes, or you log an account in while the client is running, the next call reconnects. No client restart needed.
93
+ - **stdio and Streamable HTTP upstreams**, with `${ENV}` expansion that fails loudly when a variable is unset.
94
+
95
+ ## Compared with
96
+
97
+ | | mcp-mux | [mcp-multiauth](https://github.com/TuchSoft/mcp-multiauth) | [mcp-remote](https://github.com/geelen/mcp-remote) ×N | Claude Code natively | Codex natively |
98
+ |---|---|---|---|---|---|
99
+ | One toolset + `account` argument | ✅ | ✅ | ✗ tools duplicated per account | ✗ one server per account | ✗ |
100
+ | Per-account OAuth login | ✅ | ✗ static headers / env only | ✅ | ◐ one login per endpoint | ◐ Gmail / Calendar / Contacts only |
101
+ | Pin the identity at login (`login_hint`, `team`) | ✅ | ✗ | ✅ | ✗ | ✗ |
102
+ | Encrypted or keychain token storage | ✅ | ✗ | ✗ not documented | ✅ | cloud |
103
+ | stdio upstreams | ✅ | ✅ | ✗ | ✅ | ✅ |
104
+ | Live check of every account | ✅ `status` | ✗ | ◐ one server | ◐ `claude mcp list` | ✗ |
105
+
106
+ Full comparison, including the self-hosted gateways: [docs/comparison.md](docs/comparison.md).
107
+
108
+ ## Configuration
109
+
110
+ One file describes one upstream server and its accounts. `upstream` is shared by all accounts, and each account overrides only what differs. `env`, `headers`, `oauth` and `oauth.authorizeParams` are merged one level deep.
111
+
112
+ Local (stdio) server, where each account gets different arguments or environment variables:
113
+
114
+ ```json
115
+ {
116
+ "name": "fs",
117
+ "upstream": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem"] },
118
+ "accounts": {
119
+ "work": { "args": ["-y", "@modelcontextprotocol/server-filesystem", "${MUX_WORK_DIR}"] },
120
+ "personal": { "args": ["-y", "@modelcontextprotocol/server-filesystem", "${MUX_PERSONAL_DIR}"],
121
+ "env": { "API_TOKEN": "${PERSONAL_TOKEN}" } }
122
+ }
123
+ }
124
+ ```
125
+
126
+ | Field | Meaning |
127
+ |---|---|
128
+ | `name` | Server name, also the credential key prefix. `[A-Za-z0-9_-]`. |
129
+ | `upstream.command` / `args` / `env` / `cwd` | stdio upstream. |
130
+ | `upstream.url` / `headers` | Streamable HTTP upstream. |
131
+ | `upstream.oauth` | Present means the upstream uses OAuth. Optional `clientId`, `clientSecret`, `scope`, `callbackPort`, `authorizeParams`. |
132
+ | `accounts.<name>` | Same fields as `upstream`, plus `description` (shown to the model). |
133
+ | `store` | `{"type":"keychain","keychain":"<path>"}` or `{"type":"file","path":"<path>"}`. The default is the login keychain on macOS, and `~/.config/mcp-mux/credentials.json` elsewhere. |
134
+
135
+ Some servers don't support dynamic client registration; Slack is one. For those, register an OAuth app yourself and set `oauth.clientId`, `oauth.clientSecret`, and a fixed `oauth.callbackPort` whose redirect URI `http://127.0.0.1:<port>/callback` is registered with the app.
136
+
137
+ Secrets: `${VAR}` is read from mcp-mux's own environment. MCP clients start servers with only the variables you configure, so put secrets (and `MCP_MUX_STORE_KEY` when using the file store) in the client's `env` for this server, for example `claude mcp add notion -e MCP_MUX_STORE_KEY=... -- /absolute/path/to/bun /absolute/path/to/mcp-mux/src/cli.ts serve ...`.
138
+
139
+ ### Client config files
140
+
141
+ Claude Code `.mcp.json`:
142
+
143
+ ```json
144
+ { "mcpServers": { "notion": { "command": "/absolute/path/to/bun", "args": ["/absolute/path/to/mcp-mux/src/cli.ts", "serve", "/abs/path/notion.mux.json"] } } }
145
+ ```
146
+
147
+ Codex `~/.codex/config.toml`:
148
+
149
+ ```toml
150
+ [mcp_servers.notion]
151
+ command = "/absolute/path/to/bun"
152
+ args = ["/absolute/path/to/mcp-mux/src/cli.ts", "serve", "/abs/path/notion.mux.json"]
153
+ ```
154
+
155
+ ## Commands
156
+
157
+ In a source checkout, replace the `mcp-mux` command below with `bun src/cli.ts`.
158
+
159
+ ```text
160
+ mcp-mux serve <config.json> run as a stdio MCP server
161
+ mcp-mux login <config.json> <account> [--no-browser] OAuth login for one account
162
+ mcp-mux logout <config.json> <account> delete one account's stored credentials
163
+ mcp-mux status <config.json> connect to every account and report; exit 1 if any fails
164
+ ```
165
+
166
+ ## Try it without real accounts
167
+
168
+ ```sh
169
+ git clone https://github.com/majiayu000/mcp-mux && cd mcp-mux && bun install
170
+ bun examples/mock-oauth-server.ts # http://127.0.0.1:47821/mcp
171
+ bun src/cli.ts login examples/mock-mail.mux.json work # login_hint pins alice@work.example
172
+ bun src/cli.ts login examples/mock-mail.mux.json home # pick alice@home.example on the consent page
173
+ claude mcp add mail -- bun "$PWD/src/cli.ts" serve "$PWD/examples/mock-mail.mux.json"
174
+ ```
175
+
176
+ ## Limitations
177
+
178
+ - **OAuth has only been tested against the bundled mock provider**, not real Gmail, Slack or Notion. Reports from real providers are welcome.
179
+ - Local clients only: Claude Code, Codex, Claude Desktop. claude.ai on the web, the mobile apps and cloud routines connect only to remote URLs.
180
+ - Only tools are proxied. Resources, prompts and `tools/list_changed` aren't passed through yet.
181
+ - All accounts must expose the same tools. mcp-mux refuses to start otherwise and lists the difference.
182
+ - The MCP TypeScript SDK supports protocol versions up to 2025-11-25. Upstreams that only speak 2026-07-28 won't connect yet.
183
+
184
+ ## Development
185
+
186
+ ```sh
187
+ bun install
188
+ bun test # config, stores (incl. a throwaway macOS keychain), real filesystem server, OAuth end to end
189
+ bun run typecheck
190
+ ```
191
+
192
+ Design and research notes: [docs/design.md](docs/design.md), [docs/research.md](docs/research.md), [docs/comparison.md](docs/comparison.md).
193
+
194
+ ## License
195
+
196
+ MIT
package/package.json CHANGED
@@ -1,6 +1,60 @@
1
1
  {
2
2
  "name": "@lifcc/mcp-mux",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Use multiple accounts of one MCP server (two Gmails, several Slack or Notion workspaces) in Claude Code and Codex: one toolset with a required account argument, per-account OAuth, credentials in the keychain.",
5
+ "keywords": [
6
+ "mcp",
7
+ "model-context-protocol",
8
+ "mcp-server",
9
+ "mcp-proxy",
10
+ "multi-account",
11
+ "multiple-accounts",
12
+ "oauth",
13
+ "oauth2",
14
+ "claude-code",
15
+ "codex",
16
+ "claude",
17
+ "gmail",
18
+ "slack",
19
+ "notion",
20
+ "linear",
21
+ "keychain",
22
+ "ai-agents"
23
+ ],
24
+ "homepage": "https://github.com/majiayu000/mcp-mux#readme",
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/majiayu000/mcp-mux.git"
28
+ },
29
+ "bugs": {
30
+ "url": "https://github.com/majiayu000/mcp-mux/issues"
31
+ },
32
+ "author": "majiayu000",
33
+ "license": "MIT",
34
+ "publishConfig": { "access": "public" },
35
+ "type": "module",
36
+ "bin": {
37
+ "mcp-mux": "src/cli.ts"
38
+ },
39
+ "files": [
40
+ "src",
41
+ "README.md",
42
+ "LICENSE"
43
+ ],
44
+ "engines": {
45
+ "bun": ">=1.3"
46
+ },
47
+ "scripts": {
48
+ "test": "bun test",
49
+ "typecheck": "tsc --noEmit"
50
+ },
51
+ "dependencies": {
52
+ "@modelcontextprotocol/sdk": "1.32.1",
53
+ "zod": "^4.6.7"
54
+ },
55
+ "devDependencies": {
56
+ "@modelcontextprotocol/server-filesystem": "^2.0.0",
57
+ "@types/bun": "^1.4.3",
58
+ "typescript": "^7.0.2"
59
+ }
60
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env bun
2
+ // mcp-mux CLI: serve | login | logout | status
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { loadConfig } from "./config.ts";
5
+ import { buildMux, checkAccounts, log, storeKey } from "./mux.ts";
6
+ import { login, openInBrowser } from "./oauth.ts";
7
+ import { openStore } from "./store.ts";
8
+
9
+ const USAGE = `usage:
10
+ mcp-mux serve <config.json> run as a stdio MCP server
11
+ mcp-mux login <config.json> <account> [--no-browser]
12
+ OAuth login for one account (opens a browser; --no-browser only prints the URL)
13
+ mcp-mux logout <config.json> <account> delete one account's stored credentials
14
+ mcp-mux status <config.json> connect to every account and report what works`;
15
+
16
+ async function main(argv: string[]) {
17
+ const noBrowser = argv.includes("--no-browser");
18
+ const [cmd, path, account] = argv.filter((a) => a !== "--no-browser");
19
+ if (!cmd || !path || cmd === "-h" || cmd === "--help") {
20
+ console.error(USAGE);
21
+ process.exit(cmd ? 0 : 2);
22
+ }
23
+ const cfg = await loadConfig(path);
24
+ const pick = () => {
25
+ if (!account) throw new Error(`${cmd} needs an account name; one of: ${Object.keys(cfg.accounts).join(", ")}`);
26
+ const a = cfg.accounts[account];
27
+ if (!a) throw new Error(`unknown account "${account}"; one of: ${Object.keys(cfg.accounts).join(", ")}`);
28
+ return a;
29
+ };
30
+
31
+ switch (cmd) {
32
+ case "serve": {
33
+ const mux = await buildMux(cfg, { loginHint: (a) => `run: mcp-mux login ${path} ${a}` });
34
+ await mux.server.connect(new StdioServerTransport());
35
+ const ready = Object.keys(cfg.accounts).filter((a) => !mux.unavailable.has(a));
36
+ log(`ready: ${mux.tools.length} tools x ${ready.length}/${Object.keys(cfg.accounts).length} accounts (${ready.join(", ")})`);
37
+ return;
38
+ }
39
+ case "login": {
40
+ const a = pick();
41
+ if (a.kind !== "http" || !a.oauth) throw new Error(`account "${account}" is not an OAuth (url + "oauth") account`);
42
+ const open = noBrowser ? (u: URL) => console.error(`[mcp-mux] open this URL to log in:\n${u.href}`) : openInBrowser;
43
+ const { tools } = await login({ account: a, key: storeKey(cfg.name, account!), store: openStore(cfg.store), open });
44
+ console.error(`[mcp-mux] logged in "${account}" (${tools} tools available)`);
45
+ return;
46
+ }
47
+ case "logout": {
48
+ pick();
49
+ await openStore(cfg.store).delete(storeKey(cfg.name, account!));
50
+ console.error(`[mcp-mux] removed stored credentials for "${account}"`);
51
+ return;
52
+ }
53
+ case "status": {
54
+ // Connects to every account the same way `serve` would, so a broken config shows up before the client does.
55
+ const results = await checkAccounts(cfg, (a) => `run: mcp-mux login ${path} ${a}`);
56
+ for (const r of results) {
57
+ const a = cfg.accounts[r.account]!;
58
+ const target = a.kind === "stdio" ? `stdio: ${a.command} ${a.args.join(" ")}` : `http: ${a.url}`;
59
+ console.log(`${r.account}\t${r.ok ? `ok (${r.tools} tools)` : r.error}\t${target}`);
60
+ }
61
+ if (results.some((r) => !r.ok)) process.exit(1);
62
+ return;
63
+ }
64
+ default:
65
+ console.error(USAGE);
66
+ process.exit(2);
67
+ }
68
+ }
69
+
70
+ main(process.argv.slice(2)).catch((e) => {
71
+ log("fatal:", e instanceof Error ? e.message : e);
72
+ process.exit(1);
73
+ });
package/src/config.ts ADDED
@@ -0,0 +1,104 @@
1
+ // Config loading: one upstream shared by all accounts, per-account overrides, ${VAR} expansion.
2
+
3
+ export type OAuthConfig = {
4
+ clientId?: string;
5
+ clientSecret?: string;
6
+ scope?: string;
7
+ callbackPort?: number;
8
+ authorizeParams?: Record<string, string>;
9
+ };
10
+
11
+ type UpstreamFields = {
12
+ command?: string;
13
+ args?: string[];
14
+ env?: Record<string, string>;
15
+ cwd?: string;
16
+ url?: string;
17
+ headers?: Record<string, string>;
18
+ oauth?: OAuthConfig;
19
+ };
20
+
21
+ export type StoreConfig = { type: "keychain"; keychain?: string } | { type: "file"; path?: string };
22
+
23
+ export type RawConfig = {
24
+ name: string;
25
+ upstream?: UpstreamFields;
26
+ accounts: Record<string, UpstreamFields & { description?: string }>;
27
+ store?: StoreConfig;
28
+ };
29
+
30
+ export type StdioAccount = { kind: "stdio"; command: string; args: string[]; env: Record<string, string>; cwd?: string };
31
+ export type HttpAccount = { kind: "http"; url: string; headers: Record<string, string>; oauth?: OAuthConfig };
32
+ // `description` is shown to the model next to the account name, e.g. the mailbox address or workspace name.
33
+ export type Account = (StdioAccount | HttpAccount) & { description?: string };
34
+
35
+ export type Config = { name: string; accounts: Record<string, Account>; store?: StoreConfig };
36
+
37
+ const NAME_RE = /^[A-Za-z0-9_-]+$/;
38
+
39
+ // Resolve ${VAR} so secrets can live in the environment instead of the config file.
40
+ export function expand(s: string, env: Record<string, string | undefined> = process.env): string {
41
+ return s.replace(/\$\{(\w+)\}/g, (_, k: string) => {
42
+ const v = env[k];
43
+ if (v === undefined) throw new Error(`config references unset env var ${k}`);
44
+ return v;
45
+ });
46
+ }
47
+
48
+ function expandDeep<T>(v: T, env: Record<string, string | undefined>): T {
49
+ if (typeof v === "string") return expand(v, env) as T;
50
+ if (Array.isArray(v)) return v.map((x) => expandDeep(x, env)) as T;
51
+ if (v && typeof v === "object") {
52
+ return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, expandDeep(x, env)])) as T;
53
+ }
54
+ return v;
55
+ }
56
+
57
+ // Account fields override upstream; env/headers/oauth/authorizeParams merge one level deeper.
58
+ function merge(base: UpstreamFields, over: UpstreamFields): UpstreamFields {
59
+ const out: UpstreamFields = { ...base, ...over };
60
+ if (base.env || over.env) out.env = { ...base.env, ...over.env };
61
+ if (base.headers || over.headers) out.headers = { ...base.headers, ...over.headers };
62
+ if (base.oauth || over.oauth) {
63
+ out.oauth = { ...base.oauth, ...over.oauth };
64
+ if (base.oauth?.authorizeParams || over.oauth?.authorizeParams) {
65
+ out.oauth.authorizeParams = { ...base.oauth?.authorizeParams, ...over.oauth?.authorizeParams };
66
+ }
67
+ }
68
+ return out;
69
+ }
70
+
71
+ function toAccount(name: string, f: UpstreamFields): Account {
72
+ if (f.command && f.url) throw new Error(`account "${name}": set either "command" or "url", not both`);
73
+ if (f.command) {
74
+ if (f.oauth) throw new Error(`account "${name}": "oauth" only applies to url upstreams`);
75
+ return { kind: "stdio", command: f.command, args: f.args ?? [], env: f.env ?? {}, cwd: f.cwd };
76
+ }
77
+ if (f.url) {
78
+ new URL(f.url); // throws on malformed URLs
79
+ return { kind: "http", url: f.url, headers: f.headers ?? {}, oauth: f.oauth };
80
+ }
81
+ throw new Error(`account "${name}": needs "command" (stdio) or "url" (http)`);
82
+ }
83
+
84
+ export function parseConfig(raw: RawConfig, env: Record<string, string | undefined> = process.env): Config {
85
+ if (typeof raw?.name !== "string" || !NAME_RE.test(raw.name)) {
86
+ throw new Error(`config "name" must match ${NAME_RE}`);
87
+ }
88
+ const names = Object.keys(raw.accounts ?? {});
89
+ if (names.length === 0) throw new Error("config has no accounts");
90
+ const accounts: Record<string, Account> = {};
91
+ for (const n of names) {
92
+ if (!NAME_RE.test(n)) throw new Error(`account name "${n}" must match ${NAME_RE}`);
93
+ const { description, ...fields } = raw.accounts[n]!;
94
+ if (description !== undefined && typeof description !== "string") throw new Error(`account "${n}": "description" must be a string`);
95
+ accounts[n] = { ...toAccount(n, expandDeep(merge(raw.upstream ?? {}, fields), env)), ...(description ? { description } : {}) };
96
+ }
97
+ return { name: raw.name, accounts, store: raw.store && expandDeep(raw.store, env) };
98
+ }
99
+
100
+ export async function loadConfig(path: string): Promise<Config> {
101
+ const file = Bun.file(path);
102
+ if (!(await file.exists())) throw new Error(`config not found: ${path}`);
103
+ return parseConfig(await file.json());
104
+ }
package/src/mux.ts ADDED
@@ -0,0 +1,231 @@
1
+ // Core proxy: one client per account, one rewritten toolset, calls routed by the required `account` argument.
2
+ import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
3
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
4
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
5
+ import {
6
+ CallToolRequestSchema,
7
+ ErrorCode,
8
+ ListToolsRequestSchema,
9
+ McpError,
10
+ type CallToolResult,
11
+ type Tool,
12
+ type ToolAnnotations,
13
+ } from "@modelcontextprotocol/sdk/types.js";
14
+ import pkg from "../package.json";
15
+ import type { Account, Config } from "./config.ts";
16
+ import { AccountAuth, NeedsLoginError, httpTransport } from "./oauth.ts";
17
+ import { PaginatedClient } from "./paginated-client.ts";
18
+ import { openStore, type SecretStore } from "./store.ts";
19
+
20
+ export const log = (...a: unknown[]) => console.error("[mcp-mux]", ...a); // stdout is reserved for JSON-RPC
21
+
22
+ // Extension point for irreversible-action approval (docs/design.md §8). Nothing implements it yet.
23
+ export interface CallContext {
24
+ account: string;
25
+ tool: string;
26
+ args: Record<string, unknown>;
27
+ annotations?: ToolAnnotations;
28
+ }
29
+ export type ApprovalDecision = { allow: true } | { allow: false; reason: string };
30
+ export type BeforeCall = (ctx: CallContext) => Promise<ApprovalDecision>;
31
+
32
+ export const storeKey = (cfgName: string, account: string) => `${cfgName}/${account}`;
33
+
34
+ export async function connectAccount(cfgName: string, name: string, acct: Account, store?: SecretStore): Promise<Client> {
35
+ const client = new PaginatedClient({ name: `mcp-mux-${name}`, version: pkg.version });
36
+ if (acct.kind === "stdio") {
37
+ // Only the SDK's safe default env (PATH, HOME, ...) plus this account's env: other accounts' secrets stay out.
38
+ await client.connect(new StdioClientTransport({ command: acct.command, args: acct.args, env: acct.env, cwd: acct.cwd, stderr: "inherit" }));
39
+ } else {
40
+ const auth = acct.oauth ? new AccountAuth(storeKey(cfgName, name), store!, acct.oauth) : undefined;
41
+ await client.connect(httpTransport(acct, auth));
42
+ }
43
+ return client;
44
+ }
45
+
46
+ export function rewriteTools(tools: Tool[], accounts: string[], labels: Record<string, string | undefined> = {}): Tool[] {
47
+ const list = accounts.map((a) => (labels[a] ? `${a} (${labels[a]})` : a)).join(", ");
48
+ return tools.map((t) => {
49
+ const schema = t.inputSchema ?? { type: "object" };
50
+ if (schema.properties && "account" in schema.properties) {
51
+ throw new Error(`upstream tool "${t.name}" already has an "account" argument`);
52
+ }
53
+ return {
54
+ ...t,
55
+ description: `${t.description ?? ""}\n\nAccounts: ${list}.`.trim(),
56
+ inputSchema: {
57
+ ...schema,
58
+ type: "object",
59
+ properties: {
60
+ account: { type: "string", enum: accounts, description: `Which account to use: ${list}` },
61
+ ...(schema.properties ?? {}),
62
+ },
63
+ required: ["account", ...(schema.required ?? []).filter((r) => r !== "account")],
64
+ },
65
+ };
66
+ });
67
+ }
68
+
69
+ export type AccountCheck = { account: string; ok: true; tools: number } | { account: string; ok: false; error: string };
70
+
71
+ const describeFailure = (account: string, e: unknown, loginHint: (account: string) => string) =>
72
+ e instanceof NeedsLoginError ? `needs login; ${loginHint(account)}` : `failed to connect: ${(e as Error).message}`;
73
+
74
+ const needsStore = (cfg: Config) => Object.values(cfg.accounts).some((a) => a.kind === "http" && a.oauth);
75
+
76
+ // Connect to each account and list its tools, without starting a server. Never opens a browser.
77
+ export async function checkAccounts(cfg: Config, loginHint: (account: string) => string, store?: SecretStore): Promise<AccountCheck[]> {
78
+ const s = needsStore(cfg) ? (store ?? openStore(cfg.store)) : undefined;
79
+ return Promise.all(
80
+ Object.entries(cfg.accounts).map(async ([n, a]): Promise<AccountCheck> => {
81
+ let client: Client | undefined;
82
+ try {
83
+ client = await connectAccount(cfg.name, n, a, s);
84
+ return { account: n, ok: true, tools: (await client.listTools()).tools.length };
85
+ } catch (e) {
86
+ return { account: n, ok: false, error: describeFailure(n, e, loginHint) };
87
+ } finally {
88
+ await client?.close().catch(() => {});
89
+ }
90
+ }),
91
+ );
92
+ }
93
+
94
+ const sig = (ts: Tool[]) => ts.map((t) => t.name).sort();
95
+
96
+ function errorResult(text: string): CallToolResult {
97
+ return { content: [{ type: "text", text }], isError: true };
98
+ }
99
+
100
+ export type Mux = { server: Server; tools: Tool[]; unavailable: Map<string, string>; close(): Promise<void> };
101
+
102
+ export async function buildMux(
103
+ cfg: Config,
104
+ opts: { store?: SecretStore; beforeCall?: BeforeCall; loginHint?: (account: string) => string } = {},
105
+ ): Promise<Mux> {
106
+ const names = Object.keys(cfg.accounts);
107
+ const store = needsStore(cfg) ? (opts.store ?? openStore(cfg.store)) : undefined;
108
+ const loginHint = opts.loginHint ?? ((a: string) => `run: mcp-mux login <config> ${a}`);
109
+
110
+ const clients = new Map<string, Client>();
111
+ const unavailable = new Map<string, string>();
112
+ const describe = (n: string, e: unknown) => describeFailure(n, e, loginHint);
113
+
114
+ // A closed upstream (crashed process, dropped session) is dropped here and reconnected on the next call.
115
+ const track = (n: string, c: Client) => {
116
+ clients.set(n, c);
117
+ unavailable.delete(n);
118
+ c.onclose = () => {
119
+ if (clients.get(n) !== c) return;
120
+ clients.delete(n);
121
+ unavailable.set(n, "upstream connection closed");
122
+ log(`account "${n}" upstream connection closed; will reconnect on next call`);
123
+ };
124
+ };
125
+
126
+ const settled = await Promise.allSettled(names.map((n) => connectAccount(cfg.name, n, cfg.accounts[n]!, store)));
127
+ settled.forEach((r, i) => {
128
+ const n = names[i]!;
129
+ if (r.status === "fulfilled") track(n, r.value);
130
+ else {
131
+ unavailable.set(n, describe(n, r.reason));
132
+ log(`account "${n}" ${unavailable.get(n)}`);
133
+ }
134
+ });
135
+ const close = async () => {
136
+ const all = [...clients.values()];
137
+ clients.clear();
138
+ await Promise.allSettled(all.map((c) => c.close()));
139
+ };
140
+ if (clients.size === 0) {
141
+ await close();
142
+ throw new Error(`no account is usable: ${[...unavailable].map(([n, why]) => `${n}: ${why}`).join("; ")}`);
143
+ }
144
+
145
+ // The first usable account (in config order) defines the toolset; every other usable account must match it.
146
+ const live = names.filter((n) => clients.has(n));
147
+ const lists = await Promise.all(live.map(async (n) => [n, (await clients.get(n)!.listTools()).tools] as const)).catch(async (e: unknown) => {
148
+ await close();
149
+ throw e;
150
+ });
151
+ const [refName, refTools] = lists[0]!;
152
+ const toolsetDiff = (ts: Tool[]) => {
153
+ const a = sig(refTools), b = sig(ts);
154
+ if (a.join() === b.join()) return undefined;
155
+ const missing = a.filter((x) => !b.includes(x)), extra = b.filter((x) => !a.includes(x));
156
+ return `different toolset than "${refName}" (missing: ${missing.join(", ") || "-"}; extra: ${extra.join(", ") || "-"})`;
157
+ };
158
+ for (const [n, ts] of lists.slice(1)) {
159
+ const diff = toolsetDiff(ts);
160
+ if (diff) {
161
+ await close();
162
+ throw new Error(`account "${n}" exposes a ${diff}`);
163
+ }
164
+ }
165
+ let tools: Tool[];
166
+ try {
167
+ tools = rewriteTools(refTools, names, Object.fromEntries(names.map((n) => [n, cfg.accounts[n]!.description])));
168
+ } catch (e) {
169
+ await close();
170
+ throw e;
171
+ }
172
+ const annotations = new Map(refTools.map((t) => [t.name, t.annotations]));
173
+
174
+ // Retry an unavailable account on demand, so `mcp-mux login` or a restarted upstream takes effect without restarting the client.
175
+ const pending = new Map<string, Promise<Client | undefined>>();
176
+ const reconnect = (n: string) => {
177
+ if (!pending.has(n)) {
178
+ const attempt = (async () => {
179
+ let c: Client | undefined;
180
+ try {
181
+ c = await connectAccount(cfg.name, n, cfg.accounts[n]!, store);
182
+ const diff = toolsetDiff((await c.listTools()).tools);
183
+ if (diff) throw new Error(`now exposes a ${diff}`);
184
+ track(n, c);
185
+ log(`account "${n}" reconnected`);
186
+ return c;
187
+ } catch (e) {
188
+ await c?.close().catch(() => {});
189
+ unavailable.set(n, describe(n, e));
190
+ return undefined;
191
+ } finally {
192
+ pending.delete(n);
193
+ }
194
+ })();
195
+ pending.set(n, attempt);
196
+ }
197
+ return pending.get(n)!;
198
+ };
199
+
200
+ const server = new Server({ name: cfg.name, version: pkg.version }, { capabilities: { tools: {} } });
201
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
202
+ server.setRequestHandler(CallToolRequestSchema, async (req): Promise<CallToolResult> => {
203
+ const { account, ...args } = (req.params.arguments ?? {}) as Record<string, unknown>;
204
+ if (typeof account !== "string" || !names.includes(account)) {
205
+ throw new McpError(ErrorCode.InvalidParams, `unknown account ${JSON.stringify(account)}; expected one of: ${names.join(", ")}`);
206
+ }
207
+ const client = clients.get(account) ?? (await reconnect(account));
208
+ if (!client) return errorResult(`account "${account}" is unavailable: ${unavailable.get(account)}`);
209
+
210
+ if (opts.beforeCall) {
211
+ const d = await opts.beforeCall({ account, tool: req.params.name, args, annotations: annotations.get(req.params.name) });
212
+ if (!d.allow) return errorResult(`denied: ${d.reason}`);
213
+ }
214
+
215
+ log(`call ${req.params.name} -> ${account}`);
216
+ try {
217
+ const res = (await client.callTool({ name: req.params.name, arguments: args })) as CallToolResult;
218
+ return { ...res, _meta: { ...res._meta, "mcp-mux/account": account } };
219
+ } catch (e) {
220
+ if (e instanceof NeedsLoginError) {
221
+ clients.delete(account);
222
+ unavailable.set(account, describe(account, e));
223
+ await client.close().catch(() => {});
224
+ return errorResult(`account "${account}" is unavailable: ${unavailable.get(account)}`);
225
+ }
226
+ return errorResult(`[account ${account}] ${(e as Error).message}`);
227
+ }
228
+ });
229
+
230
+ return { server, tools, unavailable, close };
231
+ }
package/src/oauth.ts ADDED
@@ -0,0 +1,222 @@
1
+ // Per-account OAuth: an OAuthClientProvider whose state lives under one store key, plus the interactive login flow.
2
+ import { randomUUID } from "node:crypto";
3
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
4
+ import { UnauthorizedError, type OAuthClientProvider, type OAuthDiscoveryState } from "@modelcontextprotocol/sdk/client/auth.js";
5
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
6
+ import type { OAuthClientInformationMixed, OAuthClientMetadata, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
7
+ import type { HttpAccount, OAuthConfig } from "./config.ts";
8
+ import type { SecretStore } from "./store.ts";
9
+
10
+ export class NeedsLoginError extends Error {}
11
+
12
+ type Saved = { tokens?: OAuthTokens; client?: OAuthClientInformationMixed; discovery?: OAuthDiscoveryState };
13
+
14
+ type Interactive = { redirectUrl: string; open: (url: URL) => void | Promise<void> };
15
+
16
+ export class AccountAuth implements OAuthClientProvider {
17
+ readonly stateValue = randomUUID();
18
+ private saved?: Saved;
19
+ private verifier?: string;
20
+
21
+ // Without `interactive` (serve mode) the provider never starts a browser flow: it throws NeedsLoginError instead.
22
+ constructor(
23
+ readonly key: string,
24
+ private store: SecretStore,
25
+ private oauth: OAuthConfig,
26
+ private interactive?: Interactive,
27
+ ) {}
28
+
29
+ private async load(): Promise<Saved> {
30
+ if (!this.saved) {
31
+ const raw = await this.store.get(this.key);
32
+ this.saved = raw ? JSON.parse(raw) : {};
33
+ }
34
+ return this.saved!;
35
+ }
36
+
37
+ private async patch(p: Partial<Saved>) {
38
+ this.saved = { ...(await this.load()), ...p };
39
+ await this.store.set(this.key, JSON.stringify(this.saved));
40
+ }
41
+
42
+ get redirectUrl() {
43
+ // Serve mode never redirects; a placeholder keeps the SDK on the authorization-code path instead of client_credentials.
44
+ return this.interactive?.redirectUrl ?? "http://127.0.0.1/callback";
45
+ }
46
+
47
+ get clientMetadata(): OAuthClientMetadata {
48
+ return {
49
+ client_name: "mcp-mux",
50
+ redirect_uris: [this.redirectUrl],
51
+ grant_types: ["authorization_code", "refresh_token"],
52
+ response_types: ["code"],
53
+ token_endpoint_auth_method: this.oauth.clientSecret ? "client_secret_post" : "none",
54
+ ...(this.oauth.scope ? { scope: this.oauth.scope } : {}),
55
+ application_type: "native",
56
+ } as OAuthClientMetadata;
57
+ }
58
+
59
+ state() {
60
+ return this.stateValue;
61
+ }
62
+
63
+ async clientInformation() {
64
+ if (this.oauth.clientId) return { client_id: this.oauth.clientId, client_secret: this.oauth.clientSecret };
65
+ const client = (await this.load()).client;
66
+ if (!this.interactive) {
67
+ if (!client) throw new NeedsLoginError("not logged in");
68
+ return client;
69
+ }
70
+ // A registration made for another callback port cannot be reused; returning nothing makes the SDK register again.
71
+ const uris = (client as { redirect_uris?: string[] } | undefined)?.redirect_uris;
72
+ return uris && !uris.includes(this.redirectUrl) ? undefined : client;
73
+ }
74
+
75
+ async saveClientInformation(client: OAuthClientInformationMixed) {
76
+ if (!this.oauth.clientId) await this.patch({ client });
77
+ }
78
+
79
+ async tokens() {
80
+ return (await this.load()).tokens;
81
+ }
82
+
83
+ async saveTokens(tokens: OAuthTokens) {
84
+ await this.patch({ tokens });
85
+ }
86
+
87
+ async redirectToAuthorization(url: URL) {
88
+ if (!this.interactive) throw new NeedsLoginError("login required (no valid token and refresh failed or unavailable)");
89
+ for (const [k, v] of Object.entries(this.oauth.authorizeParams ?? {})) url.searchParams.set(k, v);
90
+ await this.interactive.open(url);
91
+ }
92
+
93
+ saveCodeVerifier(v: string) {
94
+ this.verifier = v;
95
+ }
96
+
97
+ codeVerifier() {
98
+ if (!this.verifier) throw new Error("no PKCE code verifier for this login");
99
+ return this.verifier;
100
+ }
101
+
102
+ async saveDiscoveryState(discovery: OAuthDiscoveryState) {
103
+ await this.patch({ discovery });
104
+ }
105
+
106
+ async discoveryState() {
107
+ return (await this.load()).discovery;
108
+ }
109
+
110
+ async invalidateCredentials(scope: "all" | "client" | "tokens" | "verifier" | "discovery") {
111
+ if (scope === "verifier") this.verifier = undefined;
112
+ else if (scope === "all") await this.patch({ tokens: undefined, client: undefined, discovery: undefined });
113
+ else await this.patch({ [scope === "client" ? "client" : scope]: undefined });
114
+ }
115
+ }
116
+
117
+ export function httpTransport(account: HttpAccount, auth?: AccountAuth) {
118
+ return new StreamableHTTPClientTransport(new URL(account.url), {
119
+ authProvider: auth,
120
+ requestInit: { headers: account.headers },
121
+ });
122
+ }
123
+
124
+ type CallbackResult = { code: string } | { error: string };
125
+
126
+ // Local redirect target. Bound to 127.0.0.1 only; validates state and the RFC 9207 `iss` parameter.
127
+ function startCallbackServer(port: number, auth: () => AccountAuth) {
128
+ let settle!: (r: CallbackResult) => void;
129
+ const result = new Promise<CallbackResult>((r) => (settle = r));
130
+ const page = (msg: string, status = 200) =>
131
+ new Response(`<!doctype html><meta charset="utf-8"><title>mcp-mux</title><p>${msg}</p>`, { status, headers: { "content-type": "text/html" } });
132
+ const server = Bun.serve({
133
+ hostname: "127.0.0.1",
134
+ port,
135
+ async fetch(req) {
136
+ const u = new URL(req.url);
137
+ if (u.pathname !== "/callback") return new Response("not found", { status: 404 });
138
+ const q = u.searchParams;
139
+ if (q.get("state") !== auth().stateValue) {
140
+ settle({ error: "state mismatch" });
141
+ return page("Login failed: state mismatch.", 400);
142
+ }
143
+ const md = (await auth().discoveryState())?.authorizationServerMetadata as
144
+ | { issuer?: string; authorization_response_iss_parameter_supported?: boolean }
145
+ | undefined;
146
+ const issuer = md?.issuer;
147
+ const issRequired = md?.authorization_response_iss_parameter_supported === true;
148
+ const iss = q.get("iss");
149
+ if ((iss === null && issRequired) || (iss !== null && iss !== issuer)) {
150
+ settle({ error: `issuer mismatch (expected ${issuer}, got ${iss})` }); // do not act on error fields from a mismatched issuer
151
+ return page("Login failed: issuer mismatch.", 400);
152
+ }
153
+ if (q.get("error")) {
154
+ settle({ error: `${q.get("error")}: ${q.get("error_description") ?? ""}`.trim() });
155
+ return page("Login failed. You can close this tab.", 400);
156
+ }
157
+ const code = q.get("code");
158
+ if (!code) {
159
+ settle({ error: "callback without code" });
160
+ return page("Login failed: no code.", 400);
161
+ }
162
+ settle({ code });
163
+ return page("Logged in. You can close this tab and return to the terminal.");
164
+ },
165
+ });
166
+ return { server, result };
167
+ }
168
+
169
+ export async function openInBrowser(url: URL) {
170
+ console.error(`[mcp-mux] open this URL to log in:\n${url.href}`);
171
+ const cmd = process.platform === "darwin" ? ["open"] : process.platform === "win32" ? ["cmd", "/c", "start", ""] : ["xdg-open"];
172
+ try {
173
+ Bun.spawn([...cmd, url.href], { stdout: "ignore", stderr: "ignore" });
174
+ } catch (e) {
175
+ console.error(`[mcp-mux] could not open a browser (${(e as Error).message}); open the URL manually`);
176
+ }
177
+ }
178
+
179
+ // Interactive login for one account. Always starts a fresh authorization so the user can pick the identity.
180
+ export async function login(opts: {
181
+ account: HttpAccount;
182
+ key: string;
183
+ store: SecretStore;
184
+ open?: (url: URL) => void | Promise<void>;
185
+ timeoutMs?: number;
186
+ }): Promise<{ tools: number }> {
187
+ const oauth = opts.account.oauth ?? {};
188
+ let auth!: AccountAuth;
189
+ const { server, result } = startCallbackServer(oauth.callbackPort ?? 0, () => auth);
190
+ auth = new AccountAuth(opts.key, opts.store, oauth, {
191
+ redirectUrl: `http://127.0.0.1:${server.port}/callback`,
192
+ open: opts.open ?? openInBrowser,
193
+ });
194
+ let timer: Timer | undefined;
195
+ try {
196
+ await auth.invalidateCredentials("tokens");
197
+ const transport = httpTransport(opts.account, auth);
198
+ const first = new Client({ name: "mcp-mux-login", version: "0.1.0" });
199
+ try {
200
+ await first.connect(transport);
201
+ throw new Error("upstream accepted the request without a token; it does not need OAuth");
202
+ } catch (e) {
203
+ if (!(e instanceof UnauthorizedError)) throw e;
204
+ }
205
+ const timeout = new Promise<CallbackResult>((r) => {
206
+ timer = setTimeout(() => r({ error: "timed out waiting for the browser callback" }), opts.timeoutMs ?? 300_000);
207
+ });
208
+ const r = await Promise.race([result, timeout]);
209
+ if ("error" in r) throw new Error(`login failed: ${r.error}`);
210
+ await transport.finishAuth(r.code);
211
+
212
+ // Verify with a fresh connection that only uses stored credentials.
213
+ const check = new Client({ name: "mcp-mux-login", version: "0.1.0" });
214
+ await check.connect(httpTransport(opts.account, new AccountAuth(opts.key, opts.store, oauth)));
215
+ const { tools } = await check.listTools();
216
+ await check.close();
217
+ return { tools: tools.length };
218
+ } finally {
219
+ clearTimeout(timer);
220
+ server.stop(true);
221
+ }
222
+ }
@@ -0,0 +1,31 @@
1
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
+ import type { AnySchema, SchemaOutput } from "@modelcontextprotocol/sdk/server/zod-compat.js";
3
+ import type { RequestOptions } from "@modelcontextprotocol/sdk/shared/protocol.js";
4
+ import { ListToolsResultSchema } from "@modelcontextprotocol/sdk/types.js";
5
+
6
+ // Aggregate before Client.listTools caches metadata, so every tool retains the SDK's call validation.
7
+ export class PaginatedClient extends Client {
8
+ override async request<T extends AnySchema>(
9
+ request: Parameters<Client["request"]>[0],
10
+ resultSchema: T,
11
+ options?: RequestOptions,
12
+ ): Promise<SchemaOutput<T>> {
13
+ if (request.method !== "tools/list" || !Object.is(resultSchema, ListToolsResultSchema) || request.params?.cursor !== undefined) {
14
+ return super.request(request, resultSchema, options);
15
+ }
16
+
17
+ const first = await super.request(request, ListToolsResultSchema, options);
18
+ const tools = [...first.tools];
19
+ const cursors = new Set<string>();
20
+ let cursor = first.nextCursor;
21
+ while (cursor !== undefined) {
22
+ if (cursors.has(cursor)) throw new Error("upstream tools/list returned a repeated cursor");
23
+ cursors.add(cursor);
24
+ const page = await super.request({ ...request, params: { ...request.params, cursor } }, ListToolsResultSchema, options);
25
+ tools.push(...page.tools);
26
+ cursor = page.nextCursor;
27
+ }
28
+ // The identity check above limits this result to the SDK's canonical list-tools schema.
29
+ return { ...first, tools, nextCursor: undefined } as SchemaOutput<T>;
30
+ }
31
+ }
package/src/store.ts ADDED
@@ -0,0 +1,123 @@
1
+ // Per-account credential storage: macOS Keychain (via the `security` CLI) or an AES-256-GCM encrypted file.
2
+ import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
3
+ import { chmod, mkdir, rename } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { dirname, join } from "node:path";
6
+ import type { StoreConfig } from "./config.ts";
7
+
8
+ export interface SecretStore {
9
+ get(key: string): Promise<string | undefined>;
10
+ set(key: string, value: string): Promise<void>;
11
+ delete(key: string): Promise<void>;
12
+ }
13
+
14
+ const SERVICE = "mcp-mux";
15
+ const NOT_FOUND = 44; // `security` exit code for errSecItemNotFound
16
+
17
+ export class KeychainStore implements SecretStore {
18
+ constructor(private keychain?: string) {}
19
+
20
+ private async run(args: string[], stdin?: string): Promise<{ code: number; out: string; err: string }> {
21
+ const p = Bun.spawn(["security", ...args], { stdin: stdin === undefined ? "ignore" : "pipe", stdout: "pipe", stderr: "pipe" });
22
+ if (stdin !== undefined) {
23
+ p.stdin!.write(stdin);
24
+ p.stdin!.end();
25
+ }
26
+ const [out, err, code] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text(), p.exited]);
27
+ return { code, out, err };
28
+ }
29
+
30
+ async get(key: string) {
31
+ const r = await this.run(["find-generic-password", "-s", SERVICE, "-a", key, "-w", ...(this.keychain ? [this.keychain] : [])]);
32
+ if (r.code === NOT_FOUND) return undefined;
33
+ if (r.code !== 0) throw new Error(`keychain read failed for ${key}: ${r.err.trim()}`);
34
+ return Buffer.from(r.out.trim(), "base64").toString("utf8");
35
+ }
36
+
37
+ async set(key: string, value: string) {
38
+ // `security -i` reads the command from stdin, so the secret never shows up in the process list.
39
+ const kc = this.keychain ? ` "${this.keychain.replace(/"/g, '\\"')}"` : "";
40
+ const b64 = Buffer.from(value, "utf8").toString("base64");
41
+ const r = await this.run(["-i"], `add-generic-password -U -s ${SERVICE} -a ${key} -w ${b64}${kc}\n`);
42
+ if (r.code !== 0 || r.err.trim()) throw new Error(`keychain write failed for ${key}: ${r.err.trim()}`);
43
+ }
44
+
45
+ async delete(key: string) {
46
+ const r = await this.run(["delete-generic-password", "-s", SERVICE, "-a", key, ...(this.keychain ? [this.keychain] : [])]);
47
+ if (r.code !== 0 && r.code !== NOT_FOUND) throw new Error(`keychain delete failed for ${key}: ${r.err.trim()}`);
48
+ }
49
+ }
50
+
51
+ type Sealed = { iv: string; tag: string; data: string };
52
+
53
+ export class EncryptedFileStore implements SecretStore {
54
+ private key: Buffer;
55
+ private queue: Promise<void> = Promise.resolve();
56
+
57
+ constructor(private path: string, keyB64: string | undefined) {
58
+ if (!keyB64) throw new Error("file store needs MCP_MUX_STORE_KEY (base64 of 32 random bytes, e.g. `openssl rand -base64 32`)");
59
+ this.key = Buffer.from(keyB64, "base64");
60
+ if (this.key.length !== 32) throw new Error("MCP_MUX_STORE_KEY must decode to exactly 32 bytes");
61
+ }
62
+
63
+ private async read(): Promise<Record<string, Sealed>> {
64
+ const f = Bun.file(this.path);
65
+ return (await f.exists()) ? f.json() : {};
66
+ }
67
+
68
+ private async write(all: Record<string, Sealed>) {
69
+ await mkdir(dirname(this.path), { recursive: true });
70
+ const tmp = `${this.path}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
71
+ await Bun.write(tmp, JSON.stringify(all, null, 2));
72
+ await chmod(tmp, 0o600);
73
+ await rename(tmp, this.path);
74
+ }
75
+
76
+ async get(key: string) {
77
+ const s = (await this.read())[key];
78
+ if (!s) return undefined;
79
+ const d = createDecipheriv("aes-256-gcm", this.key, Buffer.from(s.iv, "base64"));
80
+ d.setAAD(Buffer.from(key)); // binds the ciphertext to its key, so entries cannot be swapped between accounts
81
+ d.setAuthTag(Buffer.from(s.tag, "base64"));
82
+ try {
83
+ return Buffer.concat([d.update(Buffer.from(s.data, "base64")), d.final()]).toString("utf8");
84
+ } catch {
85
+ throw new Error(`cannot decrypt stored credentials for ${key}: wrong MCP_MUX_STORE_KEY or tampered file ${this.path}`);
86
+ }
87
+ }
88
+
89
+ // Accounts share one file, so read-modify-write runs one at a time within this process.
90
+ private update(change: (all: Record<string, Sealed>) => void): Promise<void> {
91
+ const run = this.queue.then(async () => {
92
+ const all = await this.read();
93
+ change(all);
94
+ await this.write(all);
95
+ });
96
+ this.queue = run.catch(() => {});
97
+ return run;
98
+ }
99
+
100
+ set(key: string, value: string) {
101
+ const iv = randomBytes(12);
102
+ const c = createCipheriv("aes-256-gcm", this.key, iv);
103
+ c.setAAD(Buffer.from(key));
104
+ const data = Buffer.concat([c.update(value, "utf8"), c.final()]);
105
+ const sealed = { iv: iv.toString("base64"), tag: c.getAuthTag().toString("base64"), data: data.toString("base64") };
106
+ return this.update((all) => {
107
+ all[key] = sealed;
108
+ });
109
+ }
110
+
111
+ delete(key: string) {
112
+ return this.update((all) => {
113
+ delete all[key];
114
+ });
115
+ }
116
+ }
117
+
118
+ export function openStore(cfg: StoreConfig | undefined, env: Record<string, string | undefined> = process.env): SecretStore {
119
+ const type = cfg?.type ?? (process.platform === "darwin" ? "keychain" : "file");
120
+ if (type === "keychain") return new KeychainStore(cfg?.type === "keychain" ? cfg.keychain : undefined);
121
+ const path = (cfg?.type === "file" && cfg.path) || join(homedir(), ".config", "mcp-mux", "credentials.json");
122
+ return new EncryptedFileStore(path, env.MCP_MUX_STORE_KEY);
123
+ }