@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 +21 -0
- package/README.md +195 -2
- package/package.json +58 -4
- package/src/cli.ts +73 -0
- package/src/config.ts +104 -0
- package/src/mux.ts +231 -0
- package/src/oauth.ts +222 -0
- package/src/paginated-client.ts +31 -0
- package/src/store.ts +123 -0
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
|
-
#
|
|
1
|
+
# mcp-mux: multiple accounts for any MCP server
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://github.com/majiayu000/mcp-mux/actions/workflows/ci.yml)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
[](https://bun.sh)
|
|
6
|
+
[](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
|
+

|
|
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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|