@pi-archimedes/mcp 2.5.1 → 2.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -33
- package/package.json +4 -4
- package/src/auth-flow.ts +87 -4
- package/src/auth-run.test.ts +41 -104
- package/src/auth-run.ts +75 -64
- package/src/auto-auth.test.ts +23 -81
- package/src/auto-auth.ts +8 -8
- package/src/commands-auth.test.ts +54 -172
- package/src/commands-auth.ts +9 -11
- package/src/commands.test.ts +4 -6
- package/src/index.test.ts +4 -1
package/README.md
CHANGED
|
@@ -1,39 +1,42 @@
|
|
|
1
1
|
# @pi-archimedes/mcp
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Bring the tools you already use.**
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
## What you get
|
|
8
|
-
|
|
9
|
-
- **`mcp` gateway tool** — search, describe, and call tools across all configured servers; also handles `status`, per-server tool listing, and eager `connect` without opening every server upfront
|
|
10
|
-
- **Per-server direct tools** — each server's tools registered as `{server}_{tool}` for token-efficient calls; a per-server `directTools` array narrows the set to named tools only
|
|
11
|
-
- **`/mcp` command family** — status, tools, prompts, reconnect, enable/disable, logout, auth, management panel, and setup panel — everything in one command namespace
|
|
12
|
-
- **OAuth 2.1 + PKCE** — browser auth flow for protected servers (Atlassian, Notion, GitHub, …), OS credential-store persistence, SDK-driven token refresh
|
|
13
|
-
- **Lifecycle management** — `keep-alive`, `lazy`, `lazy-keep-alive`, or `eager` per server, with configurable idle timeout
|
|
14
|
-
- **Metadata cache** — `~/.pi/agent/mcp-cache.json` (7-day validity) lets search/describe work offline and persists each server's last connection outcome so `needs-auth`/errors survive restarts
|
|
15
|
-
- **Compact two-line tool rendering** — `mcp <target>` header (cyan + orange) plus a key-arg summary; full args and output hidden until expanded with `ctrl+o`
|
|
16
|
-
- **Layered config** — six config files, lowest → highest precedence; safe single-field write-back that never touches credentials or unrelated servers
|
|
5
|
+
Your MCP servers — stdio or HTTP/SSE — can talk to Pi without leaving the terminal. One `mcp` tool reaches every server (or per-server direct tools for token-efficient calls), `/mcp` is a single command namespace for management and auth, and the setup wizard imports server definitions from Cursor, Claude Code, Claude Desktop, and VS Code. Start with `/mcp setup`; manage with `/mcp`.
|
|
17
6
|
|
|
18
7
|
## Install
|
|
19
8
|
|
|
9
|
+
Standalone:
|
|
10
|
+
|
|
20
11
|
```bash
|
|
21
12
|
pi install npm:@pi-archimedes/mcp
|
|
22
13
|
```
|
|
23
14
|
|
|
24
|
-
Or
|
|
15
|
+
Or the full suite instead:
|
|
25
16
|
|
|
26
17
|
```bash
|
|
27
18
|
pi install npm:pi-archimedes
|
|
28
19
|
```
|
|
29
20
|
|
|
21
|
+
New to Pi? Pi itself is a one-time global install and needs Node.js ≥ 22.19.0 — `npm install -g --ignore-scripts @earendil-works/pi-coding-agent`; Pi's [quickstart](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/quickstart.md) covers authentication and [provider docs](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/providers.md) list the supported providers. After installing Pi, choose one installation command above, then `cd` into your project and run `pi`, and in the session use `/login` + `/model` — the [setup section](https://github.com/danielcherubini/pi-archimedes#setup) covers the first run. `/reload` picks up both new extensions and new server configs.
|
|
22
|
+
|
|
23
|
+
## What you get
|
|
24
|
+
|
|
25
|
+
- **`mcp` gateway tool** — search, describe, and call tools across all configured servers; also `status`, per-server tool listing, and eager `connect`, without opening every server upfront.
|
|
26
|
+
- **Per-server direct tools** — each server's tools registered as `{server}_{tool}` for direct, token-efficient calls; a per-server `directTools` array narrows the set to named tools.
|
|
27
|
+
- **`/mcp` command family** — status, tools, prompts, reconnect, enable/disable, logout, auth, management panel, setup panel — one namespace.
|
|
28
|
+
- **OAuth 2.1 + PKCE** — interactive browser auth for protected servers, with OS credential-store persistence and SDK-driven refresh.
|
|
29
|
+
- **Lifecycle management** — `keep-alive`, `lazy` (default), `lazy-keep-alive`, or `eager` per server, with an idle timeout.
|
|
30
|
+
- **Metadata cache** — `~/.pi/agent/mcp-cache.json` (7-day validity) lets search/describe work offline and persists each server's last connection outcome, so `needs-auth`/errors survive restarts.
|
|
31
|
+
- **Compact two-line tool rendering** — an `mcp <target>` header with a key-argument summary; full args and output expand with `ctrl+o`.
|
|
32
|
+
- **Layered config** — six config files, lowest → highest precedence, with a safe single-field write-back that never touches credentials or unrelated servers.
|
|
33
|
+
|
|
30
34
|
## Quick start
|
|
31
35
|
|
|
32
|
-
1.
|
|
36
|
+
1. **`/mcp setup`** — the recommended path. It scaffolds `.mcp.json`, adds curated presets (context7, chrome-devtools, deepwiki, fetch), and imports server definitions from Cursor, Claude Code, Claude Desktop, and VS Code — with a preview of which names will be added before anything is written.
|
|
37
|
+
2. Or edit a config file by hand. The project-shared `<project>/.mcp.json` is usually the right place. **Merge deliberately — do not `cat > .mcp.json`**, which silently destroys settings for other servers:
|
|
33
38
|
|
|
34
|
-
```
|
|
35
|
-
# in your project root
|
|
36
|
-
cat > .mcp.json <<'EOF'
|
|
39
|
+
```json
|
|
37
40
|
{
|
|
38
41
|
"mcpServers": {
|
|
39
42
|
"context7": {
|
|
@@ -42,14 +45,10 @@ pi install npm:pi-archimedes
|
|
|
42
45
|
}
|
|
43
46
|
}
|
|
44
47
|
}
|
|
45
|
-
EOF
|
|
46
48
|
```
|
|
47
49
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
2. `/reload` to pick up the new config.
|
|
51
|
-
|
|
52
|
-
3. The `mcp` tool and direct tools (`context7_*`) are now available. Check with `/mcp status`.
|
|
50
|
+
3. `/reload` to pick up the new config.
|
|
51
|
+
4. The `mcp` tool and direct tools (`context7_*`) are now available; check with `/mcp status`.
|
|
53
52
|
|
|
54
53
|
## `/mcp` command reference
|
|
55
54
|
|
|
@@ -95,7 +94,7 @@ Onboarding for a new project. All writes target the project-shared `.mcp.json`.
|
|
|
95
94
|
|
|
96
95
|
- **Scaffold** — writes `{ "mcpServers": {} }` only when the file is absent
|
|
97
96
|
- **Add a known server** — curated preset list (context7, chrome-devtools, deepwiki, fetch); existing entries are never overwritten
|
|
98
|
-
- **Import from another tool** — discovers MCP configs from Cursor, Claude Code, Claude Desktop, and
|
|
97
|
+
- **Import from another tool** — discovers MCP configs from Cursor, Claude Code, Claude Desktop, and VS Code. The six candidate files, in scan order, are `~/.cursor/mcp.json` and `<project>/.cursor/mcp.json` (`mcpServers` key), `~/.claude/mcp.json` and `~/.claude.json` (`mcpServers`), `~/.claude/claude_desktop_config.json` (`mcpServers`), and `<project>/.vscode/mcp.json` (its key is `servers`, not `mcpServers`); shows a preview of which server names will be added before writing; names already in `.mcp.json` are kept untouched
|
|
99
98
|
|
|
100
99
|
## OAuth
|
|
101
100
|
|
|
@@ -107,9 +106,19 @@ Three paths reach the same auth entry point:
|
|
|
107
106
|
|
|
108
107
|
Token details:
|
|
109
108
|
|
|
110
|
-
- Tokens persist in the OS credential store (macOS Keychain / Windows Credential Manager / Linux
|
|
111
|
-
- Token refresh is SDK-driven; a pre-registered public client (`clientId` without `clientSecret`) is never auto-refreshed — re-run `/mcp auth <server>` when its token expires
|
|
112
|
-
- The `auth` field on http/sse servers accepts `{ "token": "…" }` (static bearer), `"oauth"` (
|
|
109
|
+
- Tokens persist in the OS credential store (macOS Keychain / Windows Credential Manager / Linux Secret Service). Storage is **fail-closed**: if the keyring is unavailable, auth operations throw a clear error — there is never a plaintext fallback.
|
|
110
|
+
- Token refresh is SDK-driven; a pre-registered public client (`clientId` without `clientSecret`) is never auto-refreshed — re-run `/mcp auth <server>` when its token expires.
|
|
111
|
+
- The `auth` field on http/sse servers accepts `{ "token": "…" }` (static bearer), `"oauth"`, or a full `McpOAuthConfig` object. `auth: "oauth"` (or a config object with at least one field below) is what enables OAuth with the default grant settings; a valid object can override them. **Omitting `auth` does not automatically enable OAuth** — there is no implicit OAuth default, but static authentication can still be supplied through `bearerTokenEnv` or `headers`:
|
|
112
|
+
|
|
113
|
+
| `McpOAuthConfig` field | Meaning |
|
|
114
|
+
|------------------------|---------|
|
|
115
|
+
| `grantType` | `"authorization_code"` (default) or `"client_credentials"` |
|
|
116
|
+
| `clientId` | client identifier |
|
|
117
|
+
| `clientSecret` | string, literal only — no `!command` resolution |
|
|
118
|
+
| `scope` | space-separated scopes |
|
|
119
|
+
| `redirectUri` | pre-registered clients only |
|
|
120
|
+
| `clientName` | human name, shown in consent screens |
|
|
121
|
+
| `authorizationServerUrl` | reserved — parsed but not yet used |
|
|
113
122
|
|
|
114
123
|
## Config files & write-back
|
|
115
124
|
|
|
@@ -120,11 +129,11 @@ Six layers load in order, lowest → highest precedence (per-server field-level
|
|
|
120
129
|
| 1 | `~/.config/mcp/mcp.json` | Global (standard MCP location) |
|
|
121
130
|
| 2 | `~/.agents/mcp.json` | Cross-agent (home) |
|
|
122
131
|
| 3 | `~/.agents/mcp/mcp.json` | Cross-agent (home, alternate) |
|
|
123
|
-
| 4 | `~/.pi/agent
|
|
132
|
+
| 4 | `<agentDir>/mcp.json` (agent directory is `$PI_CODING_AGENT_DIR`, defaulting to `~/.pi/agent`) | Pi agent directory |
|
|
124
133
|
| 5 | `<project>/.mcp.json` | Project-shared (committable) |
|
|
125
134
|
| 6 | `<project>/.pi/mcp.json` | Pi override — highest precedence |
|
|
126
135
|
|
|
127
|
-
|
|
136
|
+
The `mcp.json` files (including layer 4's `<agentDir>/mcp.json`) accept `//` comments and trailing commas (**JSONC**). That does not apply to Archimedes settings — `~/.pi/agent/settings.json` is **strict JSON**, parsed without comment support. When a higher-precedence layer changes a server's `url`, inherited `auth`/`headers`/`bearerTokenEnv` from lower layers are dropped — credentials are never sent to an endpoint you didn't explicitly configure them for.
|
|
128
137
|
|
|
129
138
|
Write-back targets:
|
|
130
139
|
|
|
@@ -135,7 +144,7 @@ Changes take effect on the next `/reload`.
|
|
|
135
144
|
|
|
136
145
|
## Settings
|
|
137
146
|
|
|
138
|
-
|
|
147
|
+
`~/.pi/agent/settings.json`, under `archimedes.mcp` (strict JSON):
|
|
139
148
|
|
|
140
149
|
| Setting | Type | Default | Description |
|
|
141
150
|
|---------|------|---------|-------------|
|
|
@@ -165,6 +174,6 @@ Per-server overrides (in the `mcp.json` server definition):
|
|
|
165
174
|
|
|
166
175
|
## Integration
|
|
167
176
|
|
|
168
|
-
|
|
177
|
+
In the [suite](https://github.com/danielcherubini/pi-archimedes), the MCP adapter is registered with the rest — tool rendering uses core's chrome and colour palette, and a blocking OAuth loader triggers the notify extension's prompts. Standalone, the full feature set works independently. On/off in the suite is managed by `/plugins` (`archimedes.mcp.enabled`, default on).
|
|
169
178
|
|
|
170
|
-
← Back to
|
|
179
|
+
← [Back to pi-archimedes](https://github.com/danielcherubini/pi-archimedes)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-archimedes/mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package"
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
18
18
|
"@napi-rs/keyring": "^1.3.0",
|
|
19
19
|
"open": "^10.2.0",
|
|
20
|
-
"@pi-archimedes/core": "2.
|
|
20
|
+
"@pi-archimedes/core": "2.6.2"
|
|
21
21
|
},
|
|
22
22
|
"peerDependencies": {
|
|
23
23
|
"@earendil-works/pi-coding-agent": ">=0.1.0",
|
|
@@ -25,8 +25,8 @@
|
|
|
25
25
|
"typebox": ">=1.1.0"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
29
|
-
"@earendil-works/pi-tui": "^0.
|
|
28
|
+
"@earendil-works/pi-coding-agent": "^0.85.1",
|
|
29
|
+
"@earendil-works/pi-tui": "^0.85.1",
|
|
30
30
|
"@types/node": "^22.0.0",
|
|
31
31
|
"typebox": "^1.1.38",
|
|
32
32
|
"typescript": "^6.0.3"
|
package/src/auth-flow.ts
CHANGED
|
@@ -57,6 +57,15 @@ export type AuthStatus =
|
|
|
57
57
|
export interface AuthenticateOptions {
|
|
58
58
|
/** Called once when the SDK builds the authorization URL (e.g. to open a browser). */
|
|
59
59
|
onAuthorizationUrl?: (url: URL) => void | Promise<void>;
|
|
60
|
+
/**
|
|
61
|
+
* Called after the authorization URL is opened, giving the caller a chance
|
|
62
|
+
* to prompt the user to paste the full callback URL (for remote/headless
|
|
63
|
+
* environments where the browser cannot reach the local callback server).
|
|
64
|
+
* Should resolve with the pasted text, or undefined/empty to cancel.
|
|
65
|
+
* When provided, it races with the local callback server; whichever wins
|
|
66
|
+
* first completes the flow.
|
|
67
|
+
*/
|
|
68
|
+
onAuthorizationInput?: (url: URL, signal: AbortSignal) => Promise<string | undefined>;
|
|
60
69
|
/** Cancels the callback wait; the flow rethrows the abort error. */
|
|
61
70
|
signal?: AbortSignal;
|
|
62
71
|
}
|
|
@@ -132,6 +141,52 @@ function describeError(error: unknown): string {
|
|
|
132
141
|
return error instanceof Error ? error.message : String(error);
|
|
133
142
|
}
|
|
134
143
|
|
|
144
|
+
/**
|
|
145
|
+
* Parse an authorization code from a pasted raw code or full callback URL.
|
|
146
|
+
* Returns `{ code, iss? }` on success, throws a descriptive error otherwise.
|
|
147
|
+
*/
|
|
148
|
+
function parseCallbackInput(input: string, expectedState?: string): { code: string; iss?: string } {
|
|
149
|
+
const trimmed = input.trim();
|
|
150
|
+
if (!trimmed) throw new Error("Authorization code or redirect URL is required");
|
|
151
|
+
|
|
152
|
+
// Try to parse as a URL (full callback redirect)
|
|
153
|
+
let params: URLSearchParams | undefined;
|
|
154
|
+
try {
|
|
155
|
+
params = new URL(trimmed).searchParams;
|
|
156
|
+
} catch {
|
|
157
|
+
// Not a URL — try treating it as a query string
|
|
158
|
+
if (trimmed.includes("code=")) {
|
|
159
|
+
try {
|
|
160
|
+
params = new URLSearchParams(trimmed.includes("?") ? trimmed.slice(trimmed.indexOf("?") + 1) : trimmed);
|
|
161
|
+
} catch {
|
|
162
|
+
// ignore
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (params) {
|
|
168
|
+
const error = params.get("error");
|
|
169
|
+
if (error) {
|
|
170
|
+
const description = params.get("error_description");
|
|
171
|
+
throw new Error(description ? `${error}: ${description}` : error);
|
|
172
|
+
}
|
|
173
|
+
const state = params.get("state");
|
|
174
|
+
if (expectedState && state && state !== expectedState) {
|
|
175
|
+
throw new Error("OAuth state mismatch — potential CSRF attack");
|
|
176
|
+
}
|
|
177
|
+
const code = params.get("code");
|
|
178
|
+
if (code) {
|
|
179
|
+
const iss = params.get("iss") ?? undefined;
|
|
180
|
+
return { code, ...(iss !== undefined ? { iss } : {}) };
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Treat as a raw authorization code
|
|
185
|
+
if (/^[A-Za-z0-9._~+/=:-]+$/.test(trimmed)) return { code: trimmed };
|
|
186
|
+
|
|
187
|
+
throw new Error("Could not find an authorization code in the provided input");
|
|
188
|
+
}
|
|
189
|
+
|
|
135
190
|
/**
|
|
136
191
|
* Build the host callbacks for the provider. exactOptionalPropertyTypes:
|
|
137
192
|
* `OAuthCallbacks.onAuthorizationUrl` is an optional property whose type
|
|
@@ -226,16 +281,44 @@ export async function authenticate(
|
|
|
226
281
|
// redirectToAuthorization → onAuthorizationUrl (opens the browser).
|
|
227
282
|
await auth(provider, { serverUrl });
|
|
228
283
|
|
|
229
|
-
const
|
|
284
|
+
const callbackPromise = waitForCallback(state, options.signal);
|
|
230
285
|
// Eager no-op rejection sink: `waitForCallback` can reject before we
|
|
231
286
|
// reach the await (already-aborted signal), and an unhandled rejection
|
|
232
287
|
// during that window would crash the process. The awaited promise below
|
|
233
288
|
// still observes the same rejection.
|
|
234
|
-
|
|
235
|
-
|
|
289
|
+
callbackPromise.catch(() => undefined);
|
|
290
|
+
|
|
291
|
+
let result: { code: string; iss?: string };
|
|
292
|
+
if (options.onAuthorizationInput) {
|
|
293
|
+
// Race the local callback server against manual user input (for remote
|
|
294
|
+
// or headless environments where the browser redirect can't reach us).
|
|
295
|
+
const inputController = new AbortController();
|
|
296
|
+
const authorizationUrl = new URL(
|
|
297
|
+
`http://localhost:${boundPort}/callback?state=${encodeURIComponent(state)}`,
|
|
298
|
+
);
|
|
299
|
+
try {
|
|
300
|
+
const winner = await Promise.race([
|
|
301
|
+
callbackPromise.then((r) => ({ source: "callback" as const, result: r })),
|
|
302
|
+
options
|
|
303
|
+
.onAuthorizationInput(authorizationUrl, inputController.signal)
|
|
304
|
+
.then((pasted) => ({ source: "manual" as const, pasted })),
|
|
305
|
+
]);
|
|
306
|
+
if (winner.source === "callback") {
|
|
307
|
+
result = winner.result;
|
|
308
|
+
} else {
|
|
309
|
+
// Manual input won — parse the pasted text
|
|
310
|
+
if (!winner.pasted?.trim()) throw new Error("OAuth authentication cancelled");
|
|
311
|
+
result = parseCallbackInput(winner.pasted, state);
|
|
312
|
+
}
|
|
313
|
+
} finally {
|
|
314
|
+
inputController.abort();
|
|
315
|
+
}
|
|
316
|
+
} else {
|
|
317
|
+
result = await callbackPromise;
|
|
318
|
+
}
|
|
236
319
|
|
|
237
320
|
// SDK: exchange the code for tokens (persists via provider.saveTokens).
|
|
238
|
-
await auth(provider, { serverUrl, authorizationCode: code });
|
|
321
|
+
await auth(provider, { serverUrl, authorizationCode: result.code });
|
|
239
322
|
return { status: "authenticated" };
|
|
240
323
|
} catch (error) {
|
|
241
324
|
if (options.signal?.aborted) throw error;
|
package/src/auth-run.test.ts
CHANGED
|
@@ -8,17 +8,8 @@ import { openAuthUrl, reconnectAfterAuth, runAuthWithLoader } from "./auth-run.j
|
|
|
8
8
|
import { loadMetadataCache, setCachePathForTest } from "./metadata-cache.js";
|
|
9
9
|
import type { ServerClient } from "./server-client.js";
|
|
10
10
|
|
|
11
|
-
// The real BorderedLoader needs a live TUI; a stub with the same surface
|
|
12
|
-
// (constructor message + onAbort) is enough to drive the auth runner.
|
|
13
11
|
vi.mock("@earendil-works/pi-coding-agent", () => ({
|
|
14
|
-
|
|
15
|
-
message: string;
|
|
16
|
-
onAbort?: () => void;
|
|
17
|
-
constructor(_tui: unknown, _theme: unknown, message: string) {
|
|
18
|
-
this.message = message;
|
|
19
|
-
}
|
|
20
|
-
dispose() {}
|
|
21
|
-
},
|
|
12
|
+
getAgentDir: () => `${process.env.TMPDIR ?? "/tmp"}/pi-archimedes-mock-agent`,
|
|
22
13
|
}));
|
|
23
14
|
vi.mock("open", () => ({ default: vi.fn().mockResolvedValue({}) }));
|
|
24
15
|
|
|
@@ -38,7 +29,7 @@ afterEach(() => {
|
|
|
38
29
|
});
|
|
39
30
|
|
|
40
31
|
const AUTH_URL = "https://as.example/authorize?state=xyz";
|
|
41
|
-
const LABEL = "Authenticating srv…
|
|
32
|
+
const LABEL = "Authenticating srv…";
|
|
42
33
|
|
|
43
34
|
// ── fakes ────────────────────────────────────────────────────────────────────
|
|
44
35
|
|
|
@@ -68,7 +59,6 @@ function makeFakeClient(opts: FakeClientOpts = {}) {
|
|
|
68
59
|
close: vi.fn().mockResolvedValue(undefined),
|
|
69
60
|
connect: vi.fn().mockImplementation(async () => {
|
|
70
61
|
if (opts.reconnectError) {
|
|
71
|
-
// Mirror the real client: a thrown connect settles into "error".
|
|
72
62
|
client.status = "error";
|
|
73
63
|
client.error = opts.reconnectError;
|
|
74
64
|
throw new Error(opts.reconnectError);
|
|
@@ -96,8 +86,6 @@ function makeFakeClient(opts: FakeClientOpts = {}) {
|
|
|
96
86
|
return Promise.reject(new Error(opts.error ?? "boom"));
|
|
97
87
|
default:
|
|
98
88
|
if (opts.invokeAuthUrl) {
|
|
99
|
-
// Resolve only after the URL hook settles, so `open()` and the
|
|
100
|
-
// notification are guaranteed to have run before success.
|
|
101
89
|
return Promise.resolve(options?.onAuthorizationUrl?.(new URL(AUTH_URL))).then(
|
|
102
90
|
() => undefined,
|
|
103
91
|
);
|
|
@@ -109,114 +97,60 @@ function makeFakeClient(opts: FakeClientOpts = {}) {
|
|
|
109
97
|
return client;
|
|
110
98
|
}
|
|
111
99
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
interface CtxState {
|
|
118
|
-
notify: ReturnType<typeof vi.fn>;
|
|
119
|
-
custom: ReturnType<typeof vi.fn>;
|
|
120
|
-
lastLoader: () => FakeLoader | null;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/** Fake ExtensionContext: custom() runs the factory synchronously and
|
|
124
|
-
* resolves when done() is first called; the loader is captured. */
|
|
125
|
-
function makeCtx(): { ctx: ExtensionContext; state: CtxState } {
|
|
126
|
-
const state: Omit<CtxState, "lastLoader"> = { notify: vi.fn(), custom: vi.fn() };
|
|
127
|
-
let lastLoader: FakeLoader | null = null;
|
|
128
|
-
state.custom.mockImplementation(
|
|
129
|
-
(factory: (
|
|
130
|
-
tui: unknown,
|
|
131
|
-
theme: unknown,
|
|
132
|
-
keybindings: unknown,
|
|
133
|
-
done: (result: unknown) => void,
|
|
134
|
-
) => unknown) => {
|
|
135
|
-
let resolve!: (result: unknown) => void;
|
|
136
|
-
const pending = new Promise<unknown>((r) => (resolve = r));
|
|
137
|
-
let settled = false;
|
|
138
|
-
const done = (result: unknown) => {
|
|
139
|
-
if (!settled) {
|
|
140
|
-
settled = true;
|
|
141
|
-
resolve(result);
|
|
142
|
-
}
|
|
143
|
-
};
|
|
144
|
-
lastLoader = factory({}, {}, {}, done) as FakeLoader | null;
|
|
145
|
-
return pending;
|
|
146
|
-
},
|
|
147
|
-
);
|
|
100
|
+
function makeCtx() {
|
|
101
|
+
const notify = vi.fn();
|
|
102
|
+
const setStatus = vi.fn();
|
|
103
|
+
const confirm = vi.fn().mockResolvedValue(false);
|
|
104
|
+
const input = vi.fn().mockResolvedValue(undefined);
|
|
148
105
|
const ctx = {
|
|
149
106
|
hasUI: true,
|
|
150
|
-
ui: { notify
|
|
107
|
+
ui: { notify, setStatus, confirm, input },
|
|
151
108
|
} as unknown as ExtensionContext;
|
|
152
|
-
return { ctx,
|
|
109
|
+
return { ctx, notify, setStatus, confirm, input };
|
|
153
110
|
}
|
|
154
111
|
|
|
155
112
|
// ── runAuthWithLoader ────────────────────────────────────────────────────────
|
|
156
113
|
|
|
157
114
|
describe("runAuthWithLoader", () => {
|
|
158
|
-
it("
|
|
115
|
+
it("notifies the URL first, opens the browser, reconnects, returns reconnected", async () => {
|
|
159
116
|
const client = makeFakeClient({ outcome: "success", invokeAuthUrl: true, toolCount: 3 });
|
|
160
|
-
const { ctx,
|
|
117
|
+
const { ctx, notify, setStatus } = makeCtx();
|
|
161
118
|
const outcome = await runAuthWithLoader(ctx, client as unknown as ServerClient, {
|
|
162
119
|
loaderLabel: LABEL,
|
|
163
120
|
});
|
|
164
121
|
|
|
165
122
|
expect(outcome).toEqual({ kind: "reconnected", status: "connected", tools: 3 });
|
|
166
|
-
//
|
|
167
|
-
expect(
|
|
168
|
-
expect(
|
|
169
|
-
//
|
|
170
|
-
expect(
|
|
171
|
-
|
|
172
|
-
signal: AbortSignal;
|
|
173
|
-
onAuthorizationUrl: (u: URL) => Promise<void>;
|
|
174
|
-
};
|
|
175
|
-
expect(opts.signal.aborted).toBe(false);
|
|
176
|
-
// Browser opened for the auth URL, user notified of it
|
|
177
|
-
expect(open).toHaveBeenCalledWith(AUTH_URL);
|
|
178
|
-
expect(state.notify).toHaveBeenCalledWith(
|
|
179
|
-
`Opening browser… if it didn't open, visit: ${AUTH_URL}`,
|
|
123
|
+
// Status set during, cleared after
|
|
124
|
+
expect(setStatus).toHaveBeenCalledWith("mcp-auth-srv", LABEL);
|
|
125
|
+
expect(setStatus).toHaveBeenLastCalledWith("mcp-auth-srv", undefined);
|
|
126
|
+
// Notification fired with URL (before open)
|
|
127
|
+
expect(notify).toHaveBeenCalledWith(
|
|
128
|
+
expect.stringContaining(AUTH_URL),
|
|
180
129
|
"info",
|
|
181
130
|
);
|
|
131
|
+
// Browser opened
|
|
132
|
+
expect(open).toHaveBeenCalledWith(AUTH_URL);
|
|
182
133
|
// Reconnect to pick up the freshly stored token
|
|
183
134
|
expect(client.close).toHaveBeenCalledTimes(1);
|
|
184
135
|
expect(client.connect).toHaveBeenCalledTimes(1);
|
|
185
136
|
});
|
|
186
137
|
|
|
187
|
-
it("
|
|
188
|
-
const client = makeFakeClient({ outcome: "
|
|
189
|
-
const { ctx,
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
});
|
|
193
|
-
await vi.waitFor(() => expect(client.authenticate).toHaveBeenCalledTimes(1));
|
|
194
|
-
const opts = client.authenticate.mock.calls[0]![0] as { signal: AbortSignal };
|
|
195
|
-
expect(opts.signal.aborted).toBe(false);
|
|
196
|
-
|
|
197
|
-
// Simulate Esc in the loader
|
|
198
|
-
state.lastLoader()!.onAbort!();
|
|
199
|
-
const outcome = await running;
|
|
200
|
-
|
|
201
|
-
expect(outcome).toEqual({ kind: "cancelled" });
|
|
202
|
-
expect(opts.signal.aborted).toBe(true);
|
|
203
|
-
expect(client.close).not.toHaveBeenCalled();
|
|
204
|
-
expect(client.connect).not.toHaveBeenCalled();
|
|
138
|
+
it("clears setStatus even when the flow fails", async () => {
|
|
139
|
+
const client = makeFakeClient({ outcome: "throw", error: "boom" });
|
|
140
|
+
const { ctx, setStatus } = makeCtx();
|
|
141
|
+
await runAuthWithLoader(ctx, client as unknown as ServerClient, { loaderLabel: LABEL });
|
|
142
|
+
expect(setStatus).toHaveBeenLastCalledWith("mcp-auth-srv", undefined);
|
|
205
143
|
});
|
|
206
144
|
|
|
207
|
-
it("treats
|
|
208
|
-
// Externally aborted flow rejects "OAuth cancelled" without the loader
|
|
209
|
-
// ever settling via onAbort.
|
|
145
|
+
it("treats 'OAuth cancelled' rejection as cancelled (no reconnect)", async () => {
|
|
210
146
|
const client = makeFakeClient({ outcome: "throw", error: "OAuth cancelled" });
|
|
211
|
-
const { ctx
|
|
212
|
-
const
|
|
147
|
+
const { ctx } = makeCtx();
|
|
148
|
+
const outcome = await runAuthWithLoader(ctx, client as unknown as ServerClient, {
|
|
213
149
|
loaderLabel: LABEL,
|
|
214
150
|
});
|
|
215
|
-
// Esc is NOT pressed — only the flow rejection decides.
|
|
216
|
-
const outcome = await running;
|
|
217
151
|
expect(outcome).toEqual({ kind: "cancelled" });
|
|
218
152
|
expect(client.close).not.toHaveBeenCalled();
|
|
219
|
-
expect(
|
|
153
|
+
expect(client.connect).not.toHaveBeenCalled();
|
|
220
154
|
});
|
|
221
155
|
|
|
222
156
|
it("surfaces other flow failures as flow-error without reconnecting", async () => {
|
|
@@ -227,10 +161,9 @@ describe("runAuthWithLoader", () => {
|
|
|
227
161
|
});
|
|
228
162
|
expect(outcome).toEqual({ kind: "flow-error", error: "token endpoint refused" });
|
|
229
163
|
expect(client.close).not.toHaveBeenCalled();
|
|
230
|
-
expect(client.connect).not.toHaveBeenCalled();
|
|
231
164
|
});
|
|
232
165
|
|
|
233
|
-
it("reports a failed reconnect as reconnect-failed
|
|
166
|
+
it("reports a failed reconnect as reconnect-failed", async () => {
|
|
234
167
|
const client = makeFakeClient({ reconnectError: "connection refused" });
|
|
235
168
|
const { ctx } = makeCtx();
|
|
236
169
|
const outcome = await runAuthWithLoader(ctx, client as unknown as ServerClient, {
|
|
@@ -248,6 +181,16 @@ describe("runAuthWithLoader", () => {
|
|
|
248
181
|
});
|
|
249
182
|
expect(outcome).toEqual({ kind: "reconnected", status: "needs-auth", tools: 0 });
|
|
250
183
|
});
|
|
184
|
+
|
|
185
|
+
it("passes onAuthorizationInput to authenticate", async () => {
|
|
186
|
+
const client = makeFakeClient({ outcome: "success" });
|
|
187
|
+
const { ctx } = makeCtx();
|
|
188
|
+
await runAuthWithLoader(ctx, client as unknown as ServerClient, { loaderLabel: LABEL });
|
|
189
|
+
const opts = client.authenticate.mock.calls[0]![0] as {
|
|
190
|
+
onAuthorizationInput?: unknown;
|
|
191
|
+
};
|
|
192
|
+
expect(typeof opts.onAuthorizationInput).toBe("function");
|
|
193
|
+
});
|
|
251
194
|
});
|
|
252
195
|
|
|
253
196
|
// ── reconnectAfterAuth ───────────────────────────────────────────────────────
|
|
@@ -271,10 +214,7 @@ describe("reconnectAfterAuth", () => {
|
|
|
271
214
|
|
|
272
215
|
it("records 'connected' in the ADR 0004 ledger after a successful reconnect", async () => {
|
|
273
216
|
const client = makeFakeClient({ statusAfterReconnect: "connected", toolCount: 2 });
|
|
274
|
-
|
|
275
|
-
expect(outcome).toEqual({ kind: "reconnected", status: "connected", tools: 2 });
|
|
276
|
-
// The persisted outcome ledger (ADR 0004) must reflect the settled
|
|
277
|
-
// connection — a stale "needs-auth" here would stick across sessions.
|
|
217
|
+
await reconnectAfterAuth(client as unknown as ServerClient);
|
|
278
218
|
const rec = loadMetadataCache().serverStatuses?.["srv"];
|
|
279
219
|
expect(rec?.status).toBe("connected");
|
|
280
220
|
expect(rec?.at).toBeTypeOf("number");
|
|
@@ -283,10 +223,7 @@ describe("reconnectAfterAuth", () => {
|
|
|
283
223
|
|
|
284
224
|
it("records 'error' with the failure message when the post-auth reconnect fails", async () => {
|
|
285
225
|
const client = makeFakeClient({ reconnectError: "connection refused" });
|
|
286
|
-
|
|
287
|
-
expect(outcome).toEqual({ kind: "reconnect-failed", error: "connection refused" });
|
|
288
|
-
// The fake settles into "error" like the real client does for a thrown
|
|
289
|
-
// connect — recordClientOutcome maps that to a recorded failure.
|
|
226
|
+
await reconnectAfterAuth(client as unknown as ServerClient);
|
|
290
227
|
const rec = loadMetadataCache().serverStatuses?.["srv"];
|
|
291
228
|
expect(rec?.status).toBe("error");
|
|
292
229
|
expect(rec?.error).toBe("connection refused");
|