@pi-archimedes/mcp 2.5.1 → 2.6.3
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 +99 -5
- 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/src/auth-run.ts
CHANGED
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Shared OAuth run
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
2
|
+
* Shared OAuth run: the single place that wraps `ServerClient.authenticate`
|
|
3
|
+
* — the SINGLE auth entry point — behind a visible status indicator and
|
|
4
|
+
* the post-auth close+reconnect that re-reads the freshly stored token from
|
|
5
|
+
* the keyring into the Bearer header.
|
|
6
|
+
*
|
|
7
|
+
* Design (fixes the BorderedLoader bug):
|
|
8
|
+
* - The authorization URL is surfaced via `ctx.ui.notify` BEFORE `open()` is
|
|
9
|
+
* called, so the user always sees it (the old BorderedLoader approach
|
|
10
|
+
* silently swallowed the notify because the loader re-painted over it).
|
|
11
|
+
* - Progress is shown via `ctx.ui.setStatus` (non-blocking) instead of a
|
|
12
|
+
* custom UI that owns the screen.
|
|
13
|
+
* - An `onAuthorizationInput` fallback is wired up so remote/headless users
|
|
14
|
+
* (where the browser redirect can't reach the local callback server) can
|
|
15
|
+
* paste the full callback URL and complete the flow.
|
|
16
|
+
* - Cancellation is driven by an AbortController tied to the session signal
|
|
17
|
+
* (headless) or a separate controller the caller can abort (UI path).
|
|
9
18
|
*
|
|
10
19
|
* Call sites: the `/mcp auth` command (`commands-auth.ts`) and the inline
|
|
11
20
|
* auto-auth's UI branch (`auto-auth.ts`). Each maps the structured
|
|
@@ -13,19 +22,17 @@
|
|
|
13
22
|
* between the two user-visible surfaces.
|
|
14
23
|
*/
|
|
15
24
|
import {
|
|
16
|
-
BorderedLoader,
|
|
17
25
|
type ExtensionContext,
|
|
18
26
|
} from "@earendil-works/pi-coding-agent";
|
|
19
27
|
import open from "open";
|
|
20
28
|
import { recordClientOutcome } from "./metadata-cache.js";
|
|
29
|
+
import type { AuthenticateOptions } from "./auth-flow.js";
|
|
21
30
|
import type { ServerClient, ServerStatus } from "./server-client.js";
|
|
22
31
|
|
|
23
32
|
/**
|
|
24
33
|
* Outcome of an auth attempt.
|
|
25
34
|
*
|
|
26
|
-
* - `cancelled` — the
|
|
27
|
-
* exactly "OAuth cancelled" (an external abort). Both call sites treat
|
|
28
|
-
* these identically.
|
|
35
|
+
* - `cancelled` — the flow was aborted (signal or "OAuth cancelled" error).
|
|
29
36
|
* - `flow-error` — the flow failed for a real reason; `error` carries the
|
|
30
37
|
* underlying message.
|
|
31
38
|
* - `reconnect-failed` — auth succeeded but close/connect threw; `error`
|
|
@@ -52,8 +59,8 @@ export async function openAuthUrl(url: string): Promise<void> {
|
|
|
52
59
|
try {
|
|
53
60
|
await open(url);
|
|
54
61
|
} catch {
|
|
55
|
-
// No browser available — swallow; the caller's notification
|
|
56
|
-
//
|
|
62
|
+
// No browser available — swallow; the caller's notification already
|
|
63
|
+
// shows the URL so the user can visit it manually.
|
|
57
64
|
}
|
|
58
65
|
}
|
|
59
66
|
|
|
@@ -81,66 +88,70 @@ export async function reconnectAfterAuth(client: ServerClient): Promise<AuthRunO
|
|
|
81
88
|
}
|
|
82
89
|
|
|
83
90
|
/**
|
|
84
|
-
* Run `ServerClient.authenticate`
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
91
|
+
* Run `ServerClient.authenticate` with a visible status indicator and an
|
|
92
|
+
* `onAuthorizationInput` fallback for remote/headless environments.
|
|
93
|
+
*
|
|
94
|
+
* - The authorization URL is shown via `ctx.ui.notify` BEFORE `open()` fires,
|
|
95
|
+
* so it is always visible regardless of whether the browser opens.
|
|
96
|
+
* - `ctx.ui.setStatus` tracks progress without owning the screen.
|
|
97
|
+
* - `ctx.ui.confirm` + `ctx.ui.input` provide a manual-paste path for users
|
|
98
|
+
* whose browser redirect cannot reach the local callback server.
|
|
99
|
+
* - On success the client is closed + reconnected so the fresh token is read
|
|
100
|
+
* from the keyring immediately.
|
|
88
101
|
*
|
|
89
|
-
* `ctx` must have a UI — headless callers (print/RPC)
|
|
90
|
-
*
|
|
102
|
+
* `ctx` must have a UI — headless callers (print/RPC) use `openAuthUrl` and
|
|
103
|
+
* `reconnectAfterAuth` directly (see `autoAuthenticate` in `auto-auth.ts`).
|
|
91
104
|
*/
|
|
92
105
|
export async function runAuthWithLoader(
|
|
93
106
|
ctx: ExtensionContext,
|
|
94
107
|
client: ServerClient,
|
|
95
108
|
options: {
|
|
96
|
-
/**
|
|
109
|
+
/** Status label, e.g. `Authenticating <server>…`. */
|
|
97
110
|
loaderLabel: string;
|
|
98
111
|
},
|
|
99
112
|
): Promise<AuthRunOutcome> {
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
113
|
+
const statusKey = `mcp-auth-${client.name}`;
|
|
114
|
+
ctx.ui.setStatus(statusKey, options.loaderLabel);
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
const opts: AuthenticateOptions = {
|
|
118
|
+
onAuthorizationUrl: async (url: URL) => {
|
|
119
|
+
const urlStr = url.toString();
|
|
120
|
+
// Notify FIRST so the URL is visible before open() fires — the old
|
|
121
|
+
// BorderedLoader approach re-painted over this notification silently.
|
|
122
|
+
ctx.ui.notify(
|
|
123
|
+
`Opening browser for ${client.name}… if it didn't open, visit:\n${urlStr}`,
|
|
124
|
+
"info",
|
|
125
|
+
);
|
|
126
|
+
await openAuthUrl(urlStr);
|
|
127
|
+
},
|
|
128
|
+
onAuthorizationInput: async (url: URL, signal: AbortSignal) => {
|
|
129
|
+
// Fallback for remote/headless: ask the user to paste the callback URL.
|
|
130
|
+
const urlStr = url.toString();
|
|
131
|
+
const confirmed = await ctx.ui.confirm(
|
|
132
|
+
`Authenticate ${client.name}`,
|
|
133
|
+
`Open this URL in your browser:\n${urlStr}\n\nAfter approving, select Yes to paste the callback URL.`,
|
|
134
|
+
{ signal },
|
|
135
|
+
);
|
|
136
|
+
if (!confirmed || signal.aborted) return undefined;
|
|
137
|
+
return ctx.ui.input(
|
|
138
|
+
`Complete ${client.name} OAuth`,
|
|
139
|
+
"Paste the full callback URL from your browser address bar",
|
|
140
|
+
{ signal },
|
|
141
|
+
);
|
|
142
|
+
},
|
|
117
143
|
};
|
|
118
|
-
void client
|
|
119
|
-
.authenticate({
|
|
120
|
-
signal: controller.signal,
|
|
121
|
-
onAuthorizationUrl: async (url: URL) => {
|
|
122
|
-
await openAuthUrl(url.toString());
|
|
123
|
-
ctx.ui.notify(
|
|
124
|
-
`Opening browser… if it didn't open, visit: ${url.toString()}`,
|
|
125
|
-
"info",
|
|
126
|
-
);
|
|
127
|
-
},
|
|
128
|
-
})
|
|
129
|
-
.then(
|
|
130
|
-
() => settle({ kind: "done" }),
|
|
131
|
-
// An esc-abort already settled `null`; this rejects with
|
|
132
|
-
// "OAuth cancelled" and is swallowed by the settle guard.
|
|
133
|
-
(e: unknown) => settle({ kind: "error", error: toMessage(e) }),
|
|
134
|
-
);
|
|
135
|
-
return loader;
|
|
136
|
-
});
|
|
137
|
-
if (outcome === null) return { kind: "cancelled" };
|
|
138
|
-
if (outcome.kind === "error") {
|
|
139
|
-
// A flow aborted from OUTSIDE the loader (agent abort) rejects with
|
|
140
|
-
// "OAuth cancelled" — that is a cancellation, not a failure.
|
|
141
|
-
if (outcome.error === "OAuth cancelled") return { kind: "cancelled" };
|
|
142
|
-
return { kind: "flow-error", error: outcome.error };
|
|
143
|
-
}
|
|
144
144
|
|
|
145
|
-
|
|
145
|
+
try {
|
|
146
|
+
await client.authenticate(opts);
|
|
147
|
+
} catch (e) {
|
|
148
|
+
const msg = toMessage(e);
|
|
149
|
+
if (msg === "OAuth cancelled") return { kind: "cancelled" };
|
|
150
|
+
return { kind: "flow-error", error: msg };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return reconnectAfterAuth(client);
|
|
154
|
+
} finally {
|
|
155
|
+
ctx.ui.setStatus(statusKey, undefined);
|
|
156
|
+
}
|
|
146
157
|
}
|
package/src/auto-auth.test.ts
CHANGED
|
@@ -4,17 +4,8 @@ import open from "open";
|
|
|
4
4
|
import { autoAuthenticate, needsAuthToolResult } from "./auto-auth.js";
|
|
5
5
|
import type { ServerClient } from "./server-client.js";
|
|
6
6
|
|
|
7
|
-
// The real BorderedLoader needs a live TUI; a stub with the same surface
|
|
8
|
-
// (constructor message + onAbort) is enough to drive the auto-auth flow.
|
|
9
7
|
vi.mock("@earendil-works/pi-coding-agent", () => ({
|
|
10
|
-
|
|
11
|
-
message: string;
|
|
12
|
-
onAbort?: () => void;
|
|
13
|
-
constructor(_tui: unknown, _theme: unknown, message: string) {
|
|
14
|
-
this.message = message;
|
|
15
|
-
}
|
|
16
|
-
dispose() {}
|
|
17
|
-
},
|
|
8
|
+
getAgentDir: () => `${process.env.TMPDIR ?? "/tmp"}/pi-archimedes-mock-agent`,
|
|
18
9
|
}));
|
|
19
10
|
vi.mock("open", () => ({ default: vi.fn().mockResolvedValue({}) }));
|
|
20
11
|
|
|
@@ -23,10 +14,8 @@ const AUTH_URL = "https://as.example/authorize?state=xyz";
|
|
|
23
14
|
// ── fakes ────────────────────────────────────────────────────────────────────
|
|
24
15
|
|
|
25
16
|
interface FakeClientOpts {
|
|
26
|
-
/** success: resolves; wait: hangs until the signal aborts; throw: rejects with `error`. */
|
|
27
17
|
outcome?: "success" | "wait" | "throw";
|
|
28
18
|
error?: string;
|
|
29
|
-
/** Status the client reports after close()+connect() (default: "connected"). */
|
|
30
19
|
statusAfterReconnect?: string;
|
|
31
20
|
}
|
|
32
21
|
|
|
@@ -40,7 +29,6 @@ interface FakeClient {
|
|
|
40
29
|
authenticate: ReturnType<typeof vi.fn>;
|
|
41
30
|
}
|
|
42
31
|
|
|
43
|
-
/** Minimal needs-auth ServerClient fake — authenticate/close/connect are scripted. */
|
|
44
32
|
function makeFakeClient(opts: FakeClientOpts = {}): FakeClient {
|
|
45
33
|
const client: FakeClient = {
|
|
46
34
|
name: "srv",
|
|
@@ -58,7 +46,6 @@ function makeFakeClient(opts: FakeClientOpts = {}): FakeClient {
|
|
|
58
46
|
if (options?.signal?.aborted) return Promise.reject(new Error("OAuth cancelled"));
|
|
59
47
|
switch (opts.outcome ?? "success") {
|
|
60
48
|
case "wait":
|
|
61
|
-
// Hangs until the signal aborts — like a browser flow awaiting a callback.
|
|
62
49
|
return new Promise<void>((_resolve, reject) => {
|
|
63
50
|
options?.signal?.addEventListener(
|
|
64
51
|
"abort",
|
|
@@ -76,43 +63,17 @@ function makeFakeClient(opts: FakeClientOpts = {}): FakeClient {
|
|
|
76
63
|
return client;
|
|
77
64
|
}
|
|
78
65
|
|
|
79
|
-
|
|
80
|
-
notify
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
/** Fake ExtensionContext: custom() runs the factory synchronously and
|
|
86
|
-
* resolves when done() is first called; the loader is captured. */
|
|
87
|
-
function makeCtx(hasUI: boolean): { ctx: ExtensionContext; state: CtxState } {
|
|
88
|
-
const state: Omit<CtxState, "lastLoader"> = { notify: vi.fn(), custom: vi.fn() };
|
|
89
|
-
let lastLoader: { message: string; onAbort?: () => void } | null = null;
|
|
90
|
-
state.custom.mockImplementation(
|
|
91
|
-
(factory: (
|
|
92
|
-
tui: unknown,
|
|
93
|
-
theme: unknown,
|
|
94
|
-
keybindings: unknown,
|
|
95
|
-
done: (result: unknown) => void,
|
|
96
|
-
) => unknown) => {
|
|
97
|
-
let resolve!: (result: unknown) => void;
|
|
98
|
-
const pending = new Promise<unknown>((r) => (resolve = r));
|
|
99
|
-
let settled = false;
|
|
100
|
-
const done = (result: unknown) => {
|
|
101
|
-
if (!settled) {
|
|
102
|
-
settled = true;
|
|
103
|
-
resolve(result);
|
|
104
|
-
}
|
|
105
|
-
};
|
|
106
|
-
lastLoader = factory({}, {}, {}, done) as { message: string; onAbort?: () => void } | null;
|
|
107
|
-
return pending;
|
|
108
|
-
},
|
|
109
|
-
);
|
|
66
|
+
function makeCtx(hasUI: boolean) {
|
|
67
|
+
const notify = vi.fn();
|
|
68
|
+
const setStatus = vi.fn();
|
|
69
|
+
const confirm = vi.fn().mockResolvedValue(false);
|
|
70
|
+
const input = vi.fn().mockResolvedValue(undefined);
|
|
110
71
|
const ctx = {
|
|
111
72
|
hasUI,
|
|
112
73
|
signal: new AbortController().signal,
|
|
113
|
-
ui: { notify
|
|
74
|
+
ui: { notify, setStatus, confirm, input },
|
|
114
75
|
} as unknown as ExtensionContext;
|
|
115
|
-
return { ctx,
|
|
76
|
+
return { ctx, notify, setStatus };
|
|
116
77
|
}
|
|
117
78
|
|
|
118
79
|
// ── needsAuthToolResult ──────────────────────────────────────────────────────
|
|
@@ -140,71 +101,52 @@ describe("needsAuthToolResult", () => {
|
|
|
140
101
|
// ── autoAuthenticate ─────────────────────────────────────────────────────────
|
|
141
102
|
|
|
142
103
|
describe("autoAuthenticate", () => {
|
|
143
|
-
it("
|
|
104
|
+
it("with UI: runs authenticate, shows status, opens browser, reconnects on success", async () => {
|
|
144
105
|
const client = makeFakeClient();
|
|
145
|
-
const { ctx,
|
|
106
|
+
const { ctx, notify, setStatus } = makeCtx(true);
|
|
146
107
|
const outcome = await autoAuthenticate(ctx, client as unknown as ServerClient);
|
|
147
108
|
|
|
148
109
|
expect(outcome.proceed).toBe(true);
|
|
149
110
|
expect(outcome.error).toBeUndefined();
|
|
150
|
-
expect(state.custom).toHaveBeenCalledTimes(1);
|
|
151
|
-
expect(state.lastLoader()!.message).toContain("srv");
|
|
152
111
|
expect(client.authenticate).toHaveBeenCalledTimes(1);
|
|
153
112
|
const opts = client.authenticate.mock.calls[0]![0] as {
|
|
154
|
-
signal: AbortSignal;
|
|
155
113
|
onAuthorizationUrl: (u: URL) => Promise<void>;
|
|
114
|
+
onAuthorizationInput: unknown;
|
|
156
115
|
};
|
|
157
|
-
expect(opts.
|
|
158
|
-
// Authorization URL:
|
|
116
|
+
expect(typeof opts.onAuthorizationInput).toBe("function");
|
|
117
|
+
// Authorization URL: notify first, then open browser
|
|
159
118
|
await opts.onAuthorizationUrl(new URL(AUTH_URL));
|
|
119
|
+
expect(notify).toHaveBeenCalledWith(expect.stringContaining(AUTH_URL), "info");
|
|
160
120
|
expect(open).toHaveBeenCalledWith(AUTH_URL);
|
|
161
|
-
|
|
162
|
-
|
|
121
|
+
// Status set + cleared
|
|
122
|
+
expect(setStatus).toHaveBeenCalledWith(expect.stringContaining("srv"), expect.any(String));
|
|
123
|
+
expect(setStatus).toHaveBeenLastCalledWith(expect.stringContaining("srv"), undefined);
|
|
124
|
+
// Reconnect to pick up the freshly stored token
|
|
163
125
|
expect(client.close).toHaveBeenCalledTimes(1);
|
|
164
126
|
expect(client.connect).toHaveBeenCalledTimes(1);
|
|
165
127
|
});
|
|
166
128
|
|
|
167
|
-
it("
|
|
168
|
-
const client = makeFakeClient({ outcome: "wait" });
|
|
169
|
-
const { ctx, state } = makeCtx(true);
|
|
170
|
-
const running = autoAuthenticate(ctx, client as unknown as ServerClient);
|
|
171
|
-
await vi.waitFor(() => expect(client.authenticate).toHaveBeenCalledTimes(1));
|
|
172
|
-
const opts = client.authenticate.mock.calls[0]![0] as { signal: AbortSignal };
|
|
173
|
-
expect(opts.signal.aborted).toBe(false);
|
|
174
|
-
|
|
175
|
-
// Simulate Esc in the loader
|
|
176
|
-
state.lastLoader()!.onAbort!();
|
|
177
|
-
const outcome = await running;
|
|
178
|
-
|
|
179
|
-
expect(opts.signal.aborted).toBe(true);
|
|
180
|
-
expect(outcome.proceed).toBe(false);
|
|
181
|
-
expect(outcome.error).toBe("OAuth cancelled");
|
|
182
|
-
expect(client.close).not.toHaveBeenCalled();
|
|
183
|
-
expect(client.connect).not.toHaveBeenCalled();
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
it("surfaces flow failures without throwing and without reconnecting", async () => {
|
|
129
|
+
it("with UI: surfaces flow failures without throwing and without reconnecting", async () => {
|
|
187
130
|
const client = makeFakeClient({ outcome: "throw", error: "token endpoint refused" });
|
|
188
131
|
const { ctx } = makeCtx(true);
|
|
189
132
|
const outcome = await autoAuthenticate(ctx, client as unknown as ServerClient);
|
|
190
133
|
expect(outcome.proceed).toBe(false);
|
|
191
134
|
expect(outcome.error).toBe("token endpoint refused");
|
|
192
135
|
expect(client.close).not.toHaveBeenCalled();
|
|
193
|
-
expect(client.connect).not.toHaveBeenCalled();
|
|
194
136
|
});
|
|
195
137
|
|
|
196
|
-
it("runs
|
|
138
|
+
it("headless: runs plainly (no setStatus) when the context has no UI", async () => {
|
|
197
139
|
const client = makeFakeClient();
|
|
198
|
-
const { ctx,
|
|
140
|
+
const { ctx, setStatus } = makeCtx(false);
|
|
199
141
|
const outcome = await autoAuthenticate(ctx, client as unknown as ServerClient);
|
|
200
142
|
expect(outcome.proceed).toBe(true);
|
|
201
|
-
expect(
|
|
143
|
+
expect(setStatus).not.toHaveBeenCalled();
|
|
202
144
|
expect(client.authenticate).toHaveBeenCalledTimes(1);
|
|
203
145
|
expect(client.close).toHaveBeenCalledTimes(1);
|
|
204
146
|
expect(client.connect).toHaveBeenCalledTimes(1);
|
|
205
147
|
});
|
|
206
148
|
|
|
207
|
-
it("returns the error
|
|
149
|
+
it("headless: returns the error when the flow fails", async () => {
|
|
208
150
|
const client = makeFakeClient({ outcome: "throw", error: "keyring unavailable" });
|
|
209
151
|
const { ctx } = makeCtx(false);
|
|
210
152
|
const outcome = await autoAuthenticate(ctx, client as unknown as ServerClient);
|
package/src/auto-auth.ts
CHANGED
|
@@ -9,10 +9,10 @@
|
|
|
9
9
|
* telling the user to run `/mcp auth <server>`.
|
|
10
10
|
* - autoAuth enabled: `ServerClient.authenticate` (the SINGLE auth entry
|
|
11
11
|
* point — never `auth-flow.authenticate` directly) is called inline and the
|
|
12
|
-
* caller retries the tool call once.
|
|
13
|
-
*
|
|
14
|
-
* UI;
|
|
15
|
-
*
|
|
12
|
+
* caller retries the tool call once. `runAuthWithLoader` shows progress,
|
|
13
|
+
* notifies the URL, and provides a manual-paste fallback when the execute
|
|
14
|
+
* context has UI; headless contexts run the flow plainly and open the URL
|
|
15
|
+
* directly.
|
|
16
16
|
*
|
|
17
17
|
* The loader/cancel/reconnect machinery is shared with `/mcp auth` in
|
|
18
18
|
* `auth-run.ts`; this module maps the structured outcome onto the tool's
|
|
@@ -94,10 +94,10 @@ function toAutoAuthOutcome(outcome: AuthRunOutcome, serverName: string): AutoAut
|
|
|
94
94
|
/**
|
|
95
95
|
* Run `ServerClient.authenticate` inline.
|
|
96
96
|
*
|
|
97
|
-
* With a UI context the flow
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
* tied to the agent's abort signal when streaming.
|
|
97
|
+
* With a UI context the flow runs through `runAuthWithLoader` (same path as
|
|
98
|
+
* `/mcp auth`): status shown via setStatus, URL surfaced via notify, manual
|
|
99
|
+
* paste fallback via confirm+input. Without UI (print/RPC mode) the flow
|
|
100
|
+
* runs plainly, tied to the agent's abort signal when streaming.
|
|
101
101
|
*
|
|
102
102
|
* On success the client is closed and reconnected so the freshly stored
|
|
103
103
|
* token is re-read into the Bearer header (mirrors `/mcp auth`). Cancellation,
|