@zhijiewang/openharness 2.11.0 → 2.12.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/README.md CHANGED
@@ -378,6 +378,7 @@ mcpServers:
378
378
  ```
379
379
 
380
380
  See [docs/mcp-servers.md](docs/mcp-servers.md) for the full reference.
381
+ See [docs/mcp-servers.md](docs/mcp-servers.md#authentication) for OAuth 2.1 setup (auto-triggered on 401; `/mcp-login` and `/mcp-logout` commands available).
381
382
 
382
383
  **MCP Server Registry** — browse and install from a curated catalog:
383
384
 
@@ -17,7 +17,7 @@ import type { CommandContext, CommandResult } from "./types.js";
17
17
  /**
18
18
  * Check if input is a slash command. If so, execute it.
19
19
  */
20
- export declare function processSlashCommand(input: string, context: CommandContext): CommandResult | null;
20
+ export declare function processSlashCommand(input: string, context: CommandContext): Promise<CommandResult | null>;
21
21
  /**
22
22
  * Get all registered command names (for autocomplete/display).
23
23
  */
@@ -34,7 +34,7 @@ registerSkillCommands(register);
34
34
  /**
35
35
  * Check if input is a slash command. If so, execute it.
36
36
  */
37
- export function processSlashCommand(input, context) {
37
+ export async function processSlashCommand(input, context) {
38
38
  const trimmed = input.trim();
39
39
  if (!trimmed.startsWith("/"))
40
40
  return null;
@@ -8,7 +8,10 @@ import { gitBranch, isGitRepo, isInMergeOrRebase } from "../git/index.js";
8
8
  import { readOhConfig } from "../harness/config.js";
9
9
  import { estimateMessageTokens } from "../harness/context-warning.js";
10
10
  import { getContextWindow } from "../harness/cost.js";
11
+ import { normalizeMcpConfig } from "../mcp/config-normalize.js";
11
12
  import { connectedMcpServers } from "../mcp/loader.js";
13
+ import { getAuthStatus } from "../mcp/oauth.js";
14
+ import { mcpLoginHandler, mcpLogoutHandler } from "./mcp-auth.js";
12
15
  export function registerInfoCommands(register, getCommandMap) {
13
16
  register("help", "Show available commands", () => {
14
17
  const categories = {
@@ -39,6 +42,8 @@ export function registerInfoCommands(register, getCommandMap) {
39
42
  "doctor",
40
43
  "context",
41
44
  "mcp",
45
+ "mcp-login",
46
+ "mcp-logout",
42
47
  "mcp-registry",
43
48
  "init",
44
49
  "bug",
@@ -387,19 +392,50 @@ export function registerInfoCommands(register, getCommandMap) {
387
392
  ];
388
393
  return { output: lines.join("\n"), handled: true };
389
394
  });
390
- register("mcp", "Show MCP server status", () => {
391
- const mcp = connectedMcpServers();
392
- if (mcp.length === 0) {
395
+ register("mcp", "Show MCP server status", async () => {
396
+ const connected = connectedMcpServers();
397
+ if (connected.length === 0) {
393
398
  return {
394
399
  output: "No MCP servers connected.\nConfigure in .oh/config.yaml under mcpServers.\nRun /mcp-registry to browse available servers.",
395
400
  handled: true,
396
401
  };
397
402
  }
398
- const lines = [`MCP Servers (${mcp.length} connected):\n`];
399
- for (const name of mcp) {
400
- lines.push(` ✓ ${name}`);
403
+ const cfg = readOhConfig();
404
+ const servers = cfg?.mcpServers ?? [];
405
+ const storageDir = join(homedir(), ".oh", "credentials", "mcp");
406
+ const lines = [`MCP Servers (${connected.length} connected):`, ""];
407
+ for (const name of connected) {
408
+ const entry = servers.find((s) => s.name === name);
409
+ if (!entry) {
410
+ lines.push(` ${name.padEnd(20)} unknown —`);
411
+ continue;
412
+ }
413
+ const normalized = normalizeMcpConfig(entry, process.env);
414
+ if (normalized.kind === "error") {
415
+ lines.push(` ${name.padEnd(20)} error ${normalized.message}`);
416
+ continue;
417
+ }
418
+ const kind = normalized.cfg.type;
419
+ const status = await getAuthStatus(normalized.cfg, storageDir);
420
+ let statusText;
421
+ switch (status) {
422
+ case "n/a":
423
+ statusText = "—";
424
+ break;
425
+ case "none":
426
+ statusText = "not authenticated";
427
+ break;
428
+ case "authenticated":
429
+ statusText = "authenticated";
430
+ break;
431
+ case "expired":
432
+ statusText = "expired (re-authenticate with /mcp-login)";
433
+ break;
434
+ }
435
+ lines.push(` ${name.padEnd(20)} ${kind.padEnd(6)} ${statusText}`);
401
436
  }
402
- lines.push("\nRun /mcp-registry to browse and add more servers.");
437
+ lines.push("");
438
+ lines.push("Run /mcp-registry to browse and add more servers.");
403
439
  return { output: lines.join("\n"), handled: true };
404
440
  });
405
441
  register("mcp-registry", "Browse and add MCP servers from the curated registry", (args) => {
@@ -426,6 +462,12 @@ export function registerInfoCommands(register, getCommandMap) {
426
462
  }
427
463
  return { output: `Found ${results.length} servers:\n\n${formatRegistry(results)}`, handled: true };
428
464
  });
465
+ register("mcp-login", "Authenticate to a remote MCP server via OAuth", async (args) => {
466
+ return mcpLoginHandler(args);
467
+ });
468
+ register("mcp-logout", "Wipe local OAuth tokens for an MCP server", async (args) => {
469
+ return mcpLogoutHandler(args);
470
+ });
429
471
  register("init", "Initialize project with .oh/ config", () => {
430
472
  const ohDir = join(process.cwd(), ".oh");
431
473
  if (existsSync(ohDir)) {
@@ -0,0 +1,11 @@
1
+ export type CommandResult = {
2
+ output: string;
3
+ handled: true;
4
+ };
5
+ export declare function mcpLogoutHandler(name: string, opts?: {
6
+ storageDir?: string;
7
+ }): Promise<CommandResult>;
8
+ export declare function mcpLoginHandler(name: string, opts?: {
9
+ storageDir?: string;
10
+ }): Promise<CommandResult>;
11
+ //# sourceMappingURL=mcp-auth.d.ts.map
@@ -0,0 +1,57 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import { readOhConfig } from "../harness/config.js";
4
+ import { McpClient } from "../mcp/client.js";
5
+ import { normalizeMcpConfig } from "../mcp/config-normalize.js";
6
+ import { clearTokens } from "../mcp/oauth.js";
7
+ import { loadCredentials } from "../mcp/oauth-storage.js";
8
+ function defaultStorageDir() {
9
+ return join(homedir(), ".oh", "credentials", "mcp");
10
+ }
11
+ export async function mcpLogoutHandler(name, opts = {}) {
12
+ const storageDir = opts.storageDir ?? defaultStorageDir();
13
+ const trimmed = name.trim();
14
+ if (!trimmed) {
15
+ return { output: "Usage: /mcp-logout <server-name>", handled: true };
16
+ }
17
+ const existing = await loadCredentials(storageDir, trimmed);
18
+ if (!existing) {
19
+ return { output: `No credentials stored for '${trimmed}'.`, handled: true };
20
+ }
21
+ await clearTokens(storageDir, trimmed);
22
+ return {
23
+ output: `Local token for '${trimmed}' wiped. Server-side session may remain valid until expiry.`,
24
+ handled: true,
25
+ };
26
+ }
27
+ export async function mcpLoginHandler(name, opts = {}) {
28
+ const storageDir = opts.storageDir ?? defaultStorageDir();
29
+ const trimmed = name.trim();
30
+ if (!trimmed) {
31
+ return { output: "Usage: /mcp-login <server-name>", handled: true };
32
+ }
33
+ const cfg = readOhConfig();
34
+ const servers = cfg?.mcpServers ?? [];
35
+ const entry = servers.find((s) => s.name === trimmed);
36
+ if (!entry) {
37
+ return { output: `No MCP server named '${trimmed}' in .oh/config.yaml.`, handled: true };
38
+ }
39
+ const normalized = normalizeMcpConfig(entry, process.env);
40
+ if (normalized.kind === "error") {
41
+ return { output: `Invalid config for '${trimmed}': ${normalized.message}`, handled: true };
42
+ }
43
+ if (normalized.cfg.type === "stdio") {
44
+ return { output: `Server '${trimmed}' is stdio; OAuth is not applicable.`, handled: true };
45
+ }
46
+ await clearTokens(storageDir, trimmed);
47
+ try {
48
+ const client = await McpClient.connect(entry, { storageDir });
49
+ client.disconnect();
50
+ return { output: `\u2713 Authenticated to '${trimmed}'.`, handled: true };
51
+ }
52
+ catch (err) {
53
+ const msg = err instanceof Error ? err.message : String(err);
54
+ return { output: `Authentication failed for '${trimmed}': ${msg}`, handled: true };
55
+ }
56
+ }
57
+ //# sourceMappingURL=mcp-auth.js.map
@@ -22,7 +22,7 @@ export type CommandResult = {
22
22
  /** If set, toggle fast mode */
23
23
  toggleFastMode?: boolean;
24
24
  };
25
- export type CommandHandler = (args: string, context: CommandContext) => CommandResult;
25
+ export type CommandHandler = (args: string, context: CommandContext) => CommandResult | Promise<CommandResult>;
26
26
  export type CommandContext = {
27
27
  messages: Message[];
28
28
  model: string;
@@ -405,8 +405,14 @@ export default function REPL({ provider, tools, permissionMode, systemPrompt, mo
405
405
  totalOutputTokens: costRef.current.totalOutputTokens,
406
406
  sessionId,
407
407
  };
408
- const result = processSlashCommand(trimmed, ctx);
409
- if (result) {
408
+ void processSlashCommand(trimmed, ctx).then((result) => {
409
+ if (!result) {
410
+ const userMsg = createUserMessage(input);
411
+ setMessages((prev) => [...prev, userMsg]);
412
+ pendingPromptRef.current = input;
413
+ setSubmitCount((c) => c + 1);
414
+ return;
415
+ }
410
416
  if (result.openCybergotchiSetup) {
411
417
  setShowCybergotchiSetup(true);
412
418
  return;
@@ -446,7 +452,8 @@ export default function REPL({ provider, tools, permissionMode, systemPrompt, mo
446
452
  setSubmitCount((c) => c + 1);
447
453
  return;
448
454
  }
449
- }
455
+ });
456
+ return;
450
457
  }
451
458
  const userMsg = createUserMessage(input);
452
459
  setMessages((prev) => [...prev, userMsg]);
@@ -17,11 +17,13 @@ export type McpHttpConfig = McpCommonConfig & {
17
17
  type: "http";
18
18
  url: string;
19
19
  headers?: Record<string, string>;
20
+ auth?: "oauth" | "none";
20
21
  };
21
22
  export type McpSseConfig = McpCommonConfig & {
22
23
  type: "sse";
23
24
  url: string;
24
25
  headers?: Record<string, string>;
26
+ auth?: "oauth" | "none";
25
27
  };
26
28
  export type McpServerConfig = McpStdioConfig | McpHttpConfig | McpSseConfig;
27
29
  export type HookDef = {
@@ -58,7 +58,7 @@ export async function handleUserInput(input, ctx) {
58
58
  totalOutputTokens: ctx.cost.totalOutputTokens,
59
59
  sessionId: ctx.sessionId,
60
60
  };
61
- const result = processSlashCommand(trimmed, cmdCtx);
61
+ const result = await processSlashCommand(trimmed, cmdCtx);
62
62
  if (result) {
63
63
  if (result.clearMessages)
64
64
  messages = [];
@@ -16,7 +16,11 @@ export declare class McpClient {
16
16
  private timeoutMs;
17
17
  private reconnectImpl;
18
18
  private constructor();
19
- static connect(cfg: McpServerConfig, timeoutMs?: number): Promise<McpClient>;
19
+ static connect(cfg: McpServerConfig, timeoutMsOrOpts?: number | {
20
+ timeoutMs?: number;
21
+ openFn?: (url: string) => Promise<void>;
22
+ storageDir?: string;
23
+ } | undefined): Promise<McpClient>;
20
24
  /** Test-only constructor. Not exported from the package's public API. */
21
25
  static _forTesting(opts: ForTestingOptions): McpClient;
22
26
  private defaultReconnect;
@@ -1,5 +1,12 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import open from "open";
1
4
  import { normalizeMcpConfig } from "./config-normalize.js";
5
+ import { buildAuthProvider } from "./oauth.js";
2
6
  import { buildClient, connectWithFallback } from "./transport.js";
7
+ function credentialsDir() {
8
+ return join(homedir(), ".oh", "credentials", "mcp");
9
+ }
3
10
  const DEFAULT_TIMEOUT_MS = 5_000;
4
11
  export class McpClient {
5
12
  name;
@@ -19,13 +26,29 @@ export class McpClient {
19
26
  this.instructions = instr;
20
27
  }
21
28
  }
22
- static async connect(cfg, timeoutMs = cfg.timeout ?? DEFAULT_TIMEOUT_MS) {
29
+ static async connect(cfg, timeoutMsOrOpts = undefined) {
30
+ // Backward-compatible: accept number for timeout OR options object
31
+ const opts = typeof timeoutMsOrOpts === "number" ? { timeoutMs: timeoutMsOrOpts } : (timeoutMsOrOpts ?? {});
32
+ const timeoutMs = opts.timeoutMs ?? cfg.timeout ?? DEFAULT_TIMEOUT_MS;
33
+ const openFn = opts.openFn ??
34
+ (async (url) => {
35
+ await open(url);
36
+ });
37
+ const storageDirResolved = opts.storageDir ?? credentialsDir();
23
38
  const normalized = normalizeMcpConfig(cfg, process.env);
24
39
  if (normalized.kind === "error") {
25
40
  throw new Error(normalized.message);
26
41
  }
27
- const sdk = await connectWithFallback(normalized.cfg, (c) => buildClient(c));
28
- return new McpClient(cfg.name, cfg, sdk, timeoutMs);
42
+ const authProvider = buildAuthProvider(normalized.cfg, storageDirResolved, openFn);
43
+ if (authProvider)
44
+ await authProvider.ready();
45
+ try {
46
+ const sdk = await connectWithFallback(normalized.cfg, (c) => buildClient(c, { authProvider }));
47
+ return new McpClient(cfg.name, cfg, sdk, timeoutMs);
48
+ }
49
+ finally {
50
+ authProvider?.close();
51
+ }
29
52
  }
30
53
  /** Test-only constructor. Not exported from the package's public API. */
31
54
  static _forTesting(opts) {
@@ -35,7 +58,17 @@ export class McpClient {
35
58
  const normalized = normalizeMcpConfig(this.cfg, process.env);
36
59
  if (normalized.kind === "error")
37
60
  throw new Error(normalized.message);
38
- return connectWithFallback(normalized.cfg, (c) => buildClient(c));
61
+ const authProvider = buildAuthProvider(normalized.cfg, credentialsDir(), async (url) => {
62
+ await open(url);
63
+ });
64
+ if (authProvider)
65
+ await authProvider.ready();
66
+ try {
67
+ return await connectWithFallback(normalized.cfg, (c) => buildClient(c, { authProvider }));
68
+ }
69
+ finally {
70
+ authProvider?.close();
71
+ }
39
72
  }
40
73
  async listTools() {
41
74
  const res = await this.sdk.listTools();
@@ -0,0 +1,23 @@
1
+ export type OhCredentials = {
2
+ issuerUrl: string;
3
+ clientInformation: {
4
+ client_id: string;
5
+ client_secret?: string;
6
+ } & Record<string, unknown>;
7
+ tokens: {
8
+ access_token: string;
9
+ refresh_token?: string;
10
+ expires_at?: number;
11
+ token_type?: string;
12
+ scope?: string;
13
+ };
14
+ codeVerifier?: string;
15
+ updatedAt: string;
16
+ };
17
+ /** Atomically write credentials for one server. Creates the directory with 0o700 on first use. */
18
+ export declare function saveCredentials(storageDir: string, name: string, creds: OhCredentials): Promise<void>;
19
+ /** Load credentials. Returns undefined on missing file OR corrupt JSON. Warns on world/group-readable mode. */
20
+ export declare function loadCredentials(storageDir: string, name: string): Promise<OhCredentials | undefined>;
21
+ /** Idempotent delete — ENOENT is swallowed. */
22
+ export declare function deleteCredentials(storageDir: string, name: string): Promise<void>;
23
+ //# sourceMappingURL=oauth-storage.d.ts.map
@@ -0,0 +1,58 @@
1
+ import { promises as fs } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ function pathFor(storageDir, name) {
4
+ return join(storageDir, `${name}.json`);
5
+ }
6
+ /** Atomically write credentials for one server. Creates the directory with 0o700 on first use. */
7
+ export async function saveCredentials(storageDir, name, creds) {
8
+ const filePath = pathFor(storageDir, name);
9
+ const tmpPath = `${filePath}.tmp`;
10
+ await fs.mkdir(dirname(filePath), { recursive: true, mode: 0o700 });
11
+ const body = JSON.stringify(creds, null, 2);
12
+ await fs.writeFile(tmpPath, body, { mode: 0o600 });
13
+ await fs.rename(tmpPath, filePath);
14
+ }
15
+ /** Load credentials. Returns undefined on missing file OR corrupt JSON. Warns on world/group-readable mode. */
16
+ export async function loadCredentials(storageDir, name) {
17
+ const filePath = pathFor(storageDir, name);
18
+ let raw;
19
+ try {
20
+ raw = await fs.readFile(filePath, "utf8");
21
+ }
22
+ catch (err) {
23
+ if (err.code === "ENOENT")
24
+ return undefined;
25
+ throw err;
26
+ }
27
+ try {
28
+ if (process.platform !== "win32") {
29
+ const s = await fs.stat(filePath);
30
+ if ((s.mode & 0o077) !== 0) {
31
+ console.warn(`[mcp] credentials file for '${name}' is world/group-readable; run 'chmod 600 ${filePath}'`);
32
+ }
33
+ }
34
+ }
35
+ catch {
36
+ // stat failure is non-fatal for load
37
+ }
38
+ try {
39
+ return JSON.parse(raw);
40
+ }
41
+ catch {
42
+ console.warn(`[mcp] credentials file for '${name}' is corrupt; ignoring`);
43
+ return undefined;
44
+ }
45
+ }
46
+ /** Idempotent delete — ENOENT is swallowed. */
47
+ export async function deleteCredentials(storageDir, name) {
48
+ const filePath = pathFor(storageDir, name);
49
+ try {
50
+ await fs.unlink(filePath);
51
+ }
52
+ catch (err) {
53
+ if (err.code === "ENOENT")
54
+ return;
55
+ throw err;
56
+ }
57
+ }
58
+ //# sourceMappingURL=oauth-storage.js.map
@@ -0,0 +1,79 @@
1
+ import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
2
+ import type { OAuthClientInformationMixed, OAuthClientMetadata, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
3
+ import type { NormalizedConfig } from "./config-normalize.js";
4
+ /** Thrown when the OAuth callback flow fails — user cancelled, timeout, state mismatch, etc. */
5
+ export declare class OAuthFlowError extends Error {
6
+ readonly serverName: string;
7
+ constructor(serverName: string, reason: string);
8
+ }
9
+ export type OAuthCallbackResult = {
10
+ code: string;
11
+ state: string;
12
+ };
13
+ export type PendingCallback = {
14
+ /** The full redirect URI clients should be sent to. */
15
+ readonly redirectUri: string;
16
+ /** Resolves with the captured code+state; rejects on timeout or close. */
17
+ readonly done: Promise<OAuthCallbackResult>;
18
+ /** Close the listener immediately. Idempotent. */
19
+ close: () => void;
20
+ };
21
+ /**
22
+ * Bind a single-shot HTTP listener on 127.0.0.1 to receive the OAuth redirect.
23
+ * Returns after the server has bound (so redirectUri is available synchronously on the result).
24
+ * The `done` promise resolves when a valid /oauth/callback arrives, or rejects on timeout/close.
25
+ */
26
+ export declare function awaitOAuthCallback(opts: {
27
+ timeoutMs: number;
28
+ }): Promise<PendingCallback>;
29
+ /** Strip access_token=, refresh_token=, and "Bearer <x>" from a log message. */
30
+ export declare function redactToken(msg: string): string;
31
+ export type OhOAuthProviderOptions = {
32
+ name: string;
33
+ storageDir: string;
34
+ /** Browser launch hook — injected for tests; production wires to `open` from the npm package. */
35
+ openFn: (url: string) => Promise<void>;
36
+ };
37
+ /**
38
+ * Implements the SDK's OAuthClientProvider backed by OhCredentials on disk.
39
+ * Lazily binds the callback listener on ready() (called before the SDK reads redirectUrl).
40
+ */
41
+ export declare class OhOAuthProvider implements OAuthClientProvider {
42
+ private readonly name;
43
+ private readonly storageDir;
44
+ private readonly openFn;
45
+ private pending;
46
+ private _redirectUri;
47
+ private inMemoryCodeVerifier;
48
+ constructor(opts: OhOAuthProviderOptions);
49
+ /** Bind the callback listener and prepare redirectUri. Call before first SDK access. */
50
+ ready(): Promise<void>;
51
+ /** Release the callback listener (no-op if already resolved/closed). */
52
+ close(): void;
53
+ get redirectUrl(): string | URL | undefined;
54
+ get clientMetadata(): OAuthClientMetadata;
55
+ clientInformation(): Promise<OAuthClientInformationMixed | undefined>;
56
+ saveClientInformation(info: OAuthClientInformationMixed): Promise<void>;
57
+ tokens(): Promise<OAuthTokens | undefined>;
58
+ saveTokens(tokens: OAuthTokens): Promise<void>;
59
+ redirectToAuthorization(url: URL): Promise<void>;
60
+ saveCodeVerifier(verifier: string): Promise<void>;
61
+ codeVerifier(): Promise<string>;
62
+ /** Await a resolved callback from the listener bound in ready(). */
63
+ awaitCallback(): Promise<OAuthCallbackResult>;
64
+ private emptyCreds;
65
+ }
66
+ export type AuthStatus = "n/a" | "none" | "authenticated" | "expired";
67
+ /**
68
+ * Construct an OAuth provider for a normalized config, iff:
69
+ * - type is http or sse
70
+ * - no static headers.Authorization
71
+ * - auth !== "none"
72
+ * Otherwise return undefined — the transport proceeds without OAuth.
73
+ */
74
+ export declare function buildAuthProvider(cfg: NormalizedConfig, storageDir: string, openFn: (url: string) => Promise<void>): OhOAuthProvider | undefined;
75
+ /** Delete stored credentials for a server. Safe to call when none exist. */
76
+ export declare function clearTokens(storageDir: string, name: string): Promise<void>;
77
+ /** Compute auth state for a server for /mcp display. */
78
+ export declare function getAuthStatus(cfg: NormalizedConfig, storageDir: string): Promise<AuthStatus>;
79
+ //# sourceMappingURL=oauth.d.ts.map
@@ -0,0 +1,257 @@
1
+ import { createServer } from "node:http";
2
+ import { deleteCredentials, loadCredentials, saveCredentials } from "./oauth-storage.js";
3
+ /** Thrown when the OAuth callback flow fails — user cancelled, timeout, state mismatch, etc. */
4
+ export class OAuthFlowError extends Error {
5
+ serverName;
6
+ constructor(serverName, reason) {
7
+ super(`OAuth flow for '${serverName}' failed: ${reason}`);
8
+ this.name = "OAuthFlowError";
9
+ this.serverName = serverName;
10
+ }
11
+ }
12
+ const SUCCESS_HTML = `<!doctype html><html><body style="font-family: system-ui; padding: 2rem">
13
+ <h2>Authorization complete</h2>
14
+ <p>You can close this tab and return to openHarness.</p>
15
+ </body></html>`;
16
+ /**
17
+ * Bind a single-shot HTTP listener on 127.0.0.1 to receive the OAuth redirect.
18
+ * Returns after the server has bound (so redirectUri is available synchronously on the result).
19
+ * The `done` promise resolves when a valid /oauth/callback arrives, or rejects on timeout/close.
20
+ */
21
+ export async function awaitOAuthCallback(opts) {
22
+ const server = createServer();
23
+ await new Promise((resolve, reject) => {
24
+ server.once("listening", () => resolve());
25
+ server.once("error", reject);
26
+ server.listen(0, "127.0.0.1");
27
+ });
28
+ const addr = server.address();
29
+ const redirectUri = `http://127.0.0.1:${addr.port}/oauth/callback`;
30
+ let closed = false;
31
+ let timer = null;
32
+ let resolveResult;
33
+ let rejectResult;
34
+ const done = new Promise((res, rej) => {
35
+ resolveResult = res;
36
+ rejectResult = rej;
37
+ });
38
+ function cleanup() {
39
+ if (closed)
40
+ return;
41
+ closed = true;
42
+ if (timer)
43
+ clearTimeout(timer);
44
+ timer = null;
45
+ server.close();
46
+ }
47
+ server.on("request", (req, res) => {
48
+ const host = req.headers.host ?? "";
49
+ if (!host.startsWith("127.0.0.1:")) {
50
+ res.statusCode = 403;
51
+ res.end("forbidden");
52
+ return;
53
+ }
54
+ const url = new URL(req.url ?? "/", `http://${host}`);
55
+ if (req.method !== "GET" || url.pathname !== "/oauth/callback") {
56
+ res.statusCode = 404;
57
+ res.end("not found");
58
+ return;
59
+ }
60
+ const code = url.searchParams.get("code") ?? "";
61
+ const state = url.searchParams.get("state") ?? "";
62
+ res.statusCode = 200;
63
+ res.setHeader("content-type", "text/html; charset=utf-8");
64
+ res.end(SUCCESS_HTML, () => {
65
+ cleanup();
66
+ resolveResult({ code, state });
67
+ });
68
+ });
69
+ timer = setTimeout(() => {
70
+ cleanup();
71
+ rejectResult(new Error(`OAuth callback timeout after ${opts.timeoutMs}ms`));
72
+ }, opts.timeoutMs);
73
+ return {
74
+ redirectUri,
75
+ done,
76
+ close: () => {
77
+ if (closed)
78
+ return;
79
+ cleanup();
80
+ rejectResult(new Error("OAuth callback closed before completion"));
81
+ },
82
+ };
83
+ }
84
+ /** Strip access_token=, refresh_token=, and "Bearer <x>" from a log message. */
85
+ export function redactToken(msg) {
86
+ return msg
87
+ .replace(/(access_token|refresh_token|code)=[^&\s"']+/gi, "$1=<redacted>")
88
+ .replace(/Bearer\s+[^\s"']+/gi, "Bearer <redacted>");
89
+ }
90
+ const CALLBACK_TIMEOUT_MS = 5 * 60 * 1_000;
91
+ /**
92
+ * Implements the SDK's OAuthClientProvider backed by OhCredentials on disk.
93
+ * Lazily binds the callback listener on ready() (called before the SDK reads redirectUrl).
94
+ */
95
+ export class OhOAuthProvider {
96
+ name;
97
+ storageDir;
98
+ openFn;
99
+ pending = null;
100
+ _redirectUri = null;
101
+ inMemoryCodeVerifier = null;
102
+ constructor(opts) {
103
+ this.name = opts.name;
104
+ this.storageDir = opts.storageDir;
105
+ this.openFn = opts.openFn;
106
+ }
107
+ /** Bind the callback listener and prepare redirectUri. Call before first SDK access. */
108
+ async ready() {
109
+ if (this.pending)
110
+ return;
111
+ this.pending = await awaitOAuthCallback({ timeoutMs: CALLBACK_TIMEOUT_MS });
112
+ this._redirectUri = this.pending.redirectUri;
113
+ }
114
+ /** Release the callback listener (no-op if already resolved/closed). */
115
+ close() {
116
+ if (this.pending) {
117
+ // Attach a no-op catch so the rejected `done` promise doesn't become an unhandled rejection.
118
+ this.pending.done.catch(() => { });
119
+ this.pending.close();
120
+ }
121
+ this.pending = null;
122
+ this._redirectUri = null;
123
+ }
124
+ get redirectUrl() {
125
+ return this._redirectUri ?? undefined;
126
+ }
127
+ get clientMetadata() {
128
+ return {
129
+ client_name: "openharness",
130
+ redirect_uris: this._redirectUri ? [this._redirectUri] : [],
131
+ grant_types: ["authorization_code", "refresh_token"],
132
+ response_types: ["code"],
133
+ token_endpoint_auth_method: "none",
134
+ };
135
+ }
136
+ async clientInformation() {
137
+ const creds = await loadCredentials(this.storageDir, this.name);
138
+ return creds?.clientInformation;
139
+ }
140
+ async saveClientInformation(info) {
141
+ const creds = (await loadCredentials(this.storageDir, this.name)) ?? this.emptyCreds();
142
+ creds.clientInformation = info;
143
+ creds.updatedAt = new Date().toISOString();
144
+ await saveCredentials(this.storageDir, this.name, creds);
145
+ }
146
+ async tokens() {
147
+ const creds = await loadCredentials(this.storageDir, this.name);
148
+ if (!creds?.tokens?.access_token)
149
+ return undefined;
150
+ return {
151
+ access_token: creds.tokens.access_token,
152
+ refresh_token: creds.tokens.refresh_token,
153
+ token_type: creds.tokens.token_type ?? "Bearer",
154
+ scope: creds.tokens.scope,
155
+ expires_in: creds.tokens.expires_at && creds.tokens.expires_at > Date.now()
156
+ ? Math.floor((creds.tokens.expires_at - Date.now()) / 1000)
157
+ : 0,
158
+ };
159
+ }
160
+ async saveTokens(tokens) {
161
+ const creds = (await loadCredentials(this.storageDir, this.name)) ?? this.emptyCreds();
162
+ creds.tokens = {
163
+ access_token: tokens.access_token,
164
+ refresh_token: tokens.refresh_token,
165
+ token_type: tokens.token_type ?? "Bearer",
166
+ scope: tokens.scope,
167
+ expires_at: tokens.expires_in ? Date.now() + Number(tokens.expires_in) * 1000 : undefined,
168
+ };
169
+ creds.codeVerifier = undefined;
170
+ this.inMemoryCodeVerifier = null;
171
+ creds.updatedAt = new Date().toISOString();
172
+ await saveCredentials(this.storageDir, this.name, creds);
173
+ }
174
+ async redirectToAuthorization(url) {
175
+ const urlStr = url.toString();
176
+ try {
177
+ await this.openFn(urlStr);
178
+ console.warn(`[mcp] ${this.name}: opened browser for OAuth authorization. Waiting for callback...`);
179
+ }
180
+ catch (_err) {
181
+ console.warn(`[mcp] ${this.name}: could not open browser automatically. Please open this URL manually:\n ${urlStr}`);
182
+ // Don't re-throw — the listener may still receive the callback if the user opens the URL by hand.
183
+ }
184
+ }
185
+ async saveCodeVerifier(verifier) {
186
+ this.inMemoryCodeVerifier = verifier;
187
+ const creds = (await loadCredentials(this.storageDir, this.name)) ?? this.emptyCreds();
188
+ creds.codeVerifier = verifier;
189
+ creds.updatedAt = new Date().toISOString();
190
+ await saveCredentials(this.storageDir, this.name, creds);
191
+ }
192
+ async codeVerifier() {
193
+ if (this.inMemoryCodeVerifier)
194
+ return this.inMemoryCodeVerifier;
195
+ const creds = await loadCredentials(this.storageDir, this.name);
196
+ if (!creds?.codeVerifier) {
197
+ throw new Error(`no code verifier saved for '${this.name}'`);
198
+ }
199
+ return creds.codeVerifier;
200
+ }
201
+ /** Await a resolved callback from the listener bound in ready(). */
202
+ async awaitCallback() {
203
+ if (!this.pending)
204
+ throw new Error("awaitCallback called before ready()");
205
+ try {
206
+ return await this.pending.done;
207
+ }
208
+ catch (err) {
209
+ const msg = err instanceof Error ? err.message : String(err);
210
+ throw new OAuthFlowError(this.name, msg);
211
+ }
212
+ }
213
+ emptyCreds() {
214
+ return {
215
+ issuerUrl: "",
216
+ clientInformation: { client_id: "" },
217
+ tokens: { access_token: "" },
218
+ updatedAt: new Date().toISOString(),
219
+ };
220
+ }
221
+ }
222
+ /**
223
+ * Construct an OAuth provider for a normalized config, iff:
224
+ * - type is http or sse
225
+ * - no static headers.Authorization
226
+ * - auth !== "none"
227
+ * Otherwise return undefined — the transport proceeds without OAuth.
228
+ */
229
+ export function buildAuthProvider(cfg, storageDir, openFn) {
230
+ if (cfg.type === "stdio")
231
+ return undefined;
232
+ const headers = cfg.headers;
233
+ if (headers?.Authorization)
234
+ return undefined;
235
+ if (cfg.auth === "none")
236
+ return undefined;
237
+ return new OhOAuthProvider({ name: cfg.name, storageDir, openFn });
238
+ }
239
+ /** Delete stored credentials for a server. Safe to call when none exist. */
240
+ export async function clearTokens(storageDir, name) {
241
+ await deleteCredentials(storageDir, name);
242
+ }
243
+ /** Compute auth state for a server for /mcp display. */
244
+ export async function getAuthStatus(cfg, storageDir) {
245
+ if (cfg.type === "stdio")
246
+ return "n/a";
247
+ const headers = cfg.headers;
248
+ if (headers?.Authorization)
249
+ return "n/a";
250
+ const creds = await loadCredentials(storageDir, cfg.name);
251
+ if (!creds?.tokens?.access_token)
252
+ return "none";
253
+ if (creds.tokens.expires_at && creds.tokens.expires_at <= Date.now())
254
+ return "expired";
255
+ return "authenticated";
256
+ }
257
+ //# sourceMappingURL=oauth.js.map
@@ -1,3 +1,4 @@
1
+ import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
1
2
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
3
  import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
3
4
  import type { NormalizedConfig } from "./config-normalize.js";
@@ -16,11 +17,14 @@ export declare class ProtocolError extends Error {
16
17
  readonly cause: unknown;
17
18
  constructor(serverName: string, cause: unknown);
18
19
  }
20
+ export type BuildTransportOptions = {
21
+ authProvider?: OAuthClientProvider;
22
+ };
19
23
  /**
20
24
  * Construct an SDK Transport for a normalized config.
21
25
  * Does NOT call .start() — caller (Client.connect) handles that.
22
26
  */
23
- export declare function buildTransport(cfg: NormalizedConfig): Promise<Transport>;
27
+ export declare function buildTransport(cfg: NormalizedConfig, opts?: BuildTransportOptions): Promise<Transport>;
24
28
  /**
25
29
  * Connect to an MCP server, with auto-fallback from Streamable HTTP to
26
30
  * legacy SSE when the config's type was INFERRED from url (not explicit).
@@ -30,9 +34,16 @@ export declare function buildTransport(cfg: NormalizedConfig): Promise<Transport
30
34
  * wires it to `buildClient` (Task 7).
31
35
  */
32
36
  export declare function connectWithFallback<T>(cfg: NormalizedConfig, doConnect: (cfg: NormalizedConfig) => Promise<T>): Promise<T>;
37
+ export type BuildClientOptions = {
38
+ authProvider?: OAuthClientProvider;
39
+ };
33
40
  /**
34
41
  * Build a connected SDK Client for a normalized config.
35
42
  * Maps connect-time errors into OH's typed error taxonomy.
43
+ *
44
+ * When the auth provider exposes `awaitCallback()` (i.e. OhOAuthProvider), this
45
+ * function handles the full OAuth callback → finishAuth → reconnect loop so callers
46
+ * don't need to orchestrate it manually.
36
47
  */
37
- export declare function buildClient(cfg: NormalizedConfig): Promise<Client>;
48
+ export declare function buildClient(cfg: NormalizedConfig, opts?: BuildClientOptions): Promise<Client>;
38
49
  //# sourceMappingURL=transport.d.ts.map
@@ -1,4 +1,5 @@
1
1
  import { createRequire } from "node:module";
2
+ import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";
2
3
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3
4
  import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
4
5
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
@@ -9,7 +10,7 @@ export class RemoteAuthRequiredError extends Error {
9
10
  wwwAuthenticate;
10
11
  constructor(serverName, wwwAuthenticate) {
11
12
  super(`MCP server '${serverName}' requires authentication. ` +
12
- `Add headers.Authorization to your config (OAuth flow is not yet supported).`);
13
+ `Add 'auth: oauth' to enable the OAuth 2.1 flow, or set headers.Authorization for a static bearer token.`);
13
14
  this.name = "RemoteAuthRequiredError";
14
15
  this.serverName = serverName;
15
16
  this.wwwAuthenticate = wwwAuthenticate;
@@ -41,7 +42,7 @@ export class ProtocolError extends Error {
41
42
  * Construct an SDK Transport for a normalized config.
42
43
  * Does NOT call .start() — caller (Client.connect) handles that.
43
44
  */
44
- export async function buildTransport(cfg) {
45
+ export async function buildTransport(cfg, opts = {}) {
45
46
  if (cfg.type === "stdio") {
46
47
  return new StdioClientTransport({
47
48
  command: cfg.command,
@@ -52,11 +53,13 @@ export async function buildTransport(cfg) {
52
53
  if (cfg.type === "http") {
53
54
  return new StreamableHTTPClientTransport(new URL(cfg.url), {
54
55
  requestInit: cfg.headers ? { headers: cfg.headers } : undefined,
56
+ authProvider: opts.authProvider,
55
57
  });
56
58
  }
57
59
  if (cfg.type === "sse") {
58
60
  return new SSEClientTransport(new URL(cfg.url), {
59
61
  requestInit: cfg.headers ? { headers: cfg.headers } : undefined,
62
+ authProvider: opts.authProvider,
60
63
  });
61
64
  }
62
65
  throw new Error(`unknown transport type: ${cfg.type}`);
@@ -112,7 +115,6 @@ export async function connectWithFallback(cfg, doConnect) {
112
115
  if (!isFallbackCandidate(err))
113
116
  throw err;
114
117
  // Log + retry
115
- // biome-ignore lint/suspicious/noConsole: user-facing diagnostic
116
118
  console.warn(`[mcp] ${cfg.name}: Streamable HTTP failed (${err.message}); trying legacy SSE`);
117
119
  const sseCfg = { ...cfg, type: "sse" };
118
120
  return await doConnect(sseCfg);
@@ -120,25 +122,87 @@ export async function connectWithFallback(cfg, doConnect) {
120
122
  }
121
123
  const DEFAULT_TIMEOUT_MS = 5_000;
122
124
  const CLIENT_INFO = { name: "openharness", version: pkg.version };
125
+ /** Duck-type check: does this provider expose awaitCallback (our OhOAuthProvider)? */
126
+ function hasAwaitCallback(p) {
127
+ return typeof p.awaitCallback === "function";
128
+ }
123
129
  /**
124
130
  * Build a connected SDK Client for a normalized config.
125
131
  * Maps connect-time errors into OH's typed error taxonomy.
132
+ *
133
+ * When the auth provider exposes `awaitCallback()` (i.e. OhOAuthProvider), this
134
+ * function handles the full OAuth callback → finishAuth → reconnect loop so callers
135
+ * don't need to orchestrate it manually.
126
136
  */
127
- export async function buildClient(cfg) {
128
- const transport = await buildTransport(cfg);
137
+ export async function buildClient(cfg, opts = {}) {
138
+ const transport = await buildTransport(cfg, opts);
129
139
  const client = new Client(CLIENT_INFO, { capabilities: {} });
130
140
  const timeoutMs = cfg.timeout ?? DEFAULT_TIMEOUT_MS;
131
- let timer = null;
141
+ async function tryConnect() {
142
+ let timer = null;
143
+ try {
144
+ await Promise.race([
145
+ client.connect(transport),
146
+ new Promise((_, reject) => {
147
+ timer = setTimeout(() => reject(new Error(`init timeout after ${timeoutMs}ms`)), timeoutMs);
148
+ }),
149
+ ]);
150
+ }
151
+ finally {
152
+ if (timer !== null)
153
+ clearTimeout(timer);
154
+ }
155
+ }
132
156
  try {
133
- await Promise.race([
134
- client.connect(transport),
135
- new Promise((_, reject) => {
136
- timer = setTimeout(() => reject(new Error(`init timeout after ${timeoutMs}ms`)), timeoutMs);
137
- }),
138
- ]);
157
+ await tryConnect();
139
158
  return client;
140
159
  }
141
160
  catch (err) {
161
+ // If the SDK requires a browser-based OAuth flow (UnauthorizedError after REDIRECT),
162
+ // and our provider knows how to await the callback, complete the loop here.
163
+ // Per the SDK design, after finishAuth we must create a fresh transport + client
164
+ // because the original transport is already in a "started" state.
165
+ if (err instanceof UnauthorizedError && opts.authProvider && hasAwaitCallback(opts.authProvider)) {
166
+ try {
167
+ const { code } = await opts.authProvider.awaitCallback();
168
+ await transport.finishAuth(code);
169
+ // Close the old transport before constructing a fresh one — the SDK's
170
+ // Transport is one-shot after an UnauthorizedError; leaving it open leaks
171
+ // the underlying TCP socket / event stream.
172
+ try {
173
+ await transport.close?.();
174
+ }
175
+ catch {
176
+ // best-effort
177
+ }
178
+ // Build a fresh transport + client for the authenticated retry
179
+ const freshTransport = await buildTransport(cfg, opts);
180
+ const freshClient = new Client(CLIENT_INFO, { capabilities: {} });
181
+ let freshTimer = null;
182
+ try {
183
+ await Promise.race([
184
+ freshClient.connect(freshTransport),
185
+ new Promise((_, reject) => {
186
+ freshTimer = setTimeout(() => reject(new Error(`init timeout after ${timeoutMs}ms`)), timeoutMs);
187
+ }),
188
+ ]);
189
+ }
190
+ finally {
191
+ if (freshTimer !== null)
192
+ clearTimeout(freshTimer);
193
+ }
194
+ return freshClient;
195
+ }
196
+ catch (oauthErr) {
197
+ // Classify the retry error the same way as the primary path
198
+ if (oauthErr instanceof RemoteAuthRequiredError ||
199
+ oauthErr instanceof UnreachableError ||
200
+ oauthErr instanceof ProtocolError) {
201
+ throw oauthErr;
202
+ }
203
+ throw new ProtocolError(cfg.name, oauthErr);
204
+ }
205
+ }
142
206
  // Leave RemoteAuthRequiredError / UnreachableError / ProtocolError as-is
143
207
  if (err instanceof RemoteAuthRequiredError || err instanceof UnreachableError || err instanceof ProtocolError) {
144
208
  throw err;
@@ -151,9 +215,5 @@ export async function buildClient(cfg) {
151
215
  // Otherwise protocol-shaped
152
216
  throw new ProtocolError(cfg.name, err);
153
217
  }
154
- finally {
155
- if (timer !== null)
156
- clearTimeout(timer);
157
- }
158
218
  }
159
219
  //# sourceMappingURL=transport.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhijiewang/openharness",
3
- "version": "2.11.0",
3
+ "version": "2.12.0",
4
4
  "description": "Open-source terminal coding agent. Works with any LLM.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -44,6 +44,7 @@
44
44
  "ink-spinner": "^5.0.0",
45
45
  "ink-text-input": "^6.0.0",
46
46
  "marked": "^17.0.5",
47
+ "open": "^11.0.0",
47
48
  "react": "^18.3.1",
48
49
  "yaml": "^2.7.0",
49
50
  "zod": "^3.24.0"