@zhijiewang/openharness 2.10.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
@@ -366,6 +366,20 @@ mcpServers:
366
366
 
367
367
  MCP tools appear alongside built-in tools. `/status` shows connected servers.
368
368
 
369
+ ### Remote MCP servers (HTTP / SSE)
370
+
371
+ ```yaml
372
+ mcpServers:
373
+ - name: linear
374
+ type: http
375
+ url: https://mcp.linear.app/mcp
376
+ headers:
377
+ Authorization: "Bearer ${LINEAR_API_KEY}"
378
+ ```
379
+
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).
382
+
369
383
  **MCP Server Registry** — browse and install from a curated catalog:
370
384
 
371
385
  ```
@@ -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]);
@@ -2,14 +2,30 @@
2
2
  * .oh/config.yaml — provider, model, permissionMode and other persisted settings.
3
3
  */
4
4
  import type { PermissionMode } from "../types/permissions.js";
5
- export type McpServerConfig = {
5
+ export type McpCommonConfig = {
6
6
  name: string;
7
+ riskLevel?: "low" | "medium" | "high";
8
+ timeout?: number;
9
+ };
10
+ export type McpStdioConfig = McpCommonConfig & {
11
+ type?: "stdio";
7
12
  command: string;
8
13
  args?: string[];
9
14
  env?: Record<string, string>;
10
- riskLevel?: "low" | "medium" | "high";
11
- timeout?: number;
12
15
  };
16
+ export type McpHttpConfig = McpCommonConfig & {
17
+ type: "http";
18
+ url: string;
19
+ headers?: Record<string, string>;
20
+ auth?: "oauth" | "none";
21
+ };
22
+ export type McpSseConfig = McpCommonConfig & {
23
+ type: "sse";
24
+ url: string;
25
+ headers?: Record<string, string>;
26
+ auth?: "oauth" | "none";
27
+ };
28
+ export type McpServerConfig = McpStdioConfig | McpHttpConfig | McpSseConfig;
13
29
  export type HookDef = {
14
30
  command?: string;
15
31
  http?: string;
@@ -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 = [];
@@ -1,18 +1,29 @@
1
+ import type { Client as SdkClient } from "@modelcontextprotocol/sdk/client/index.js";
1
2
  import type { McpServerConfig } from "../harness/config.js";
2
3
  import type { McpToolDef } from "./types.js";
4
+ type ForTestingOptions = {
5
+ name: string;
6
+ cfg: McpServerConfig;
7
+ sdk: SdkClient;
8
+ timeoutMs: number;
9
+ reconnect?: () => Promise<SdkClient>;
10
+ };
3
11
  export declare class McpClient {
4
12
  readonly name: string;
5
- private proc;
6
- private nextId;
7
- private pending;
8
- private ready;
9
- private dead;
13
+ instructions: string | null;
14
+ private sdk;
10
15
  private cfg;
11
16
  private timeoutMs;
17
+ private reconnectImpl;
12
18
  private constructor();
13
- /** Server-provided instructions (from capabilities during init) */
14
- instructions: string | null;
15
- 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>;
24
+ /** Test-only constructor. Not exported from the package's public API. */
25
+ static _forTesting(opts: ForTestingOptions): McpClient;
26
+ private defaultReconnect;
16
27
  listTools(): Promise<McpToolDef[]>;
17
28
  listResources(): Promise<Array<{
18
29
  uri: string;
@@ -21,8 +32,7 @@ export declare class McpClient {
21
32
  }>>;
22
33
  readResource(uri: string): Promise<string>;
23
34
  callTool(name: string, args: Record<string, unknown>): Promise<string>;
24
- private callWithTimeout;
25
- private call;
26
35
  disconnect(): void;
27
36
  }
37
+ export {};
28
38
  //# sourceMappingURL=client.d.ts.map
@@ -1,150 +1,130 @@
1
- import { spawn } from "node:child_process";
2
- import { createInterface } from "node:readline";
3
- import { safeEnv } from "../utils/safe-env.js";
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import open from "open";
4
+ import { normalizeMcpConfig } from "./config-normalize.js";
5
+ import { buildAuthProvider } from "./oauth.js";
6
+ import { buildClient, connectWithFallback } from "./transport.js";
7
+ function credentialsDir() {
8
+ return join(homedir(), ".oh", "credentials", "mcp");
9
+ }
10
+ const DEFAULT_TIMEOUT_MS = 5_000;
4
11
  export class McpClient {
5
12
  name;
6
- proc;
7
- nextId = 1;
8
- pending = new Map();
9
- // biome-ignore lint/correctness/noUnusedPrivateClassMembers: set via Object.assign in static factory
10
- ready = false;
11
- dead = false;
13
+ instructions = null;
14
+ sdk;
12
15
  cfg;
13
16
  timeoutMs;
14
- constructor(name, proc, cfg, timeoutMs) {
17
+ reconnectImpl;
18
+ constructor(name, cfg, sdk, timeoutMs, reconnect) {
15
19
  this.name = name;
16
- this.proc = proc;
17
20
  this.cfg = cfg;
21
+ this.sdk = sdk;
18
22
  this.timeoutMs = timeoutMs;
19
- const rl = createInterface({ input: proc.stdout });
20
- rl.on("line", (line) => {
21
- try {
22
- const msg = JSON.parse(line);
23
- const p = this.pending.get(msg.id);
24
- if (p) {
25
- this.pending.delete(msg.id);
26
- p.resolve(msg);
27
- }
28
- }
29
- catch {
30
- // non-JSON line from server (e.g. startup noise) — ignore
31
- }
32
- });
33
- proc.on("exit", () => {
34
- this.dead = true;
35
- for (const p of this.pending.values()) {
36
- p.reject(new Error(`MCP server '${name}' exited`));
37
- }
38
- this.pending.clear();
39
- });
23
+ this.reconnectImpl = reconnect ?? (() => this.defaultReconnect());
24
+ const instr = sdk.getInstructions?.();
25
+ if (instr && typeof instr === "string") {
26
+ this.instructions = instr;
27
+ }
40
28
  }
41
- /** Server-provided instructions (from capabilities during init) */
42
- instructions = null;
43
- static async connect(cfg, timeoutMs = cfg.timeout ?? 5_000) {
44
- const proc = spawn(cfg.command, cfg.args ?? [], {
45
- stdio: ["pipe", "pipe", "pipe"],
46
- env: safeEnv(cfg.env),
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();
38
+ const normalized = normalizeMcpConfig(cfg, process.env);
39
+ if (normalized.kind === "error") {
40
+ throw new Error(normalized.message);
41
+ }
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
+ }
52
+ }
53
+ /** Test-only constructor. Not exported from the package's public API. */
54
+ static _forTesting(opts) {
55
+ return new McpClient(opts.name, opts.cfg, opts.sdk, opts.timeoutMs, opts.reconnect);
56
+ }
57
+ async defaultReconnect() {
58
+ const normalized = normalizeMcpConfig(this.cfg, process.env);
59
+ if (normalized.kind === "error")
60
+ throw new Error(normalized.message);
61
+ const authProvider = buildAuthProvider(normalized.cfg, credentialsDir(), async (url) => {
62
+ await open(url);
47
63
  });
48
- const client = new McpClient(cfg.name, proc, cfg, timeoutMs);
49
- // Initialize handshake
50
- const initResponse = await Promise.race([
51
- client.call("initialize", {
52
- protocolVersion: "2024-11-05",
53
- clientInfo: { name: "openharness", version: "0.2.1" },
54
- capabilities: {},
55
- }),
56
- new Promise((_, reject) => setTimeout(() => reject(new Error(`MCP '${cfg.name}' init timeout`)), timeoutMs)),
57
- ]);
58
- // Extract server instructions from init response
59
- const serverInfo = initResponse?.result;
60
- if (serverInfo?.instructions && typeof serverInfo.instructions === "string") {
61
- client.instructions = serverInfo.instructions;
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();
62
71
  }
63
- await client.call("notifications/initialized", {});
64
- client.ready = true;
65
- return client;
66
72
  }
67
73
  async listTools() {
68
- const res = await this.call("tools/list", {});
69
- return (res.result?.tools ?? []);
74
+ const res = await this.sdk.listTools();
75
+ return (res?.tools ?? []);
70
76
  }
71
77
  async listResources() {
72
78
  try {
73
- const res = await this.callWithTimeout("resources/list", {});
74
- return (res.result?.resources ?? []);
79
+ const res = await this.sdk.listResources();
80
+ return (res?.resources ?? []);
75
81
  }
76
82
  catch {
77
83
  return []; // Server may not support resources
78
84
  }
79
85
  }
80
86
  async readResource(uri) {
81
- const res = await this.callWithTimeout("resources/read", { uri });
82
- if (res.error)
83
- throw new Error(res.error.message);
84
- const contents = res.result?.contents ?? [];
87
+ const res = await this.sdk.readResource({ uri });
88
+ const contents = res?.contents ?? [];
85
89
  return contents
86
- .filter((c) => c.text)
90
+ .filter((c) => typeof c.text === "string")
87
91
  .map((c) => c.text)
88
92
  .join("\n");
89
93
  }
90
94
  async callTool(name, args) {
91
- if (this.dead) {
92
- try {
93
- const fresh = await McpClient.connect(this.cfg, this.timeoutMs);
94
- Object.assign(this, { proc: fresh.proc, dead: false, ready: true, nextId: 1, pending: new Map() });
95
- }
96
- catch {
97
- throw new Error(`MCP server '${this.name}' died and restart failed`);
98
- }
99
- }
100
- // Retry up to 2 times for transient failures
101
- let lastError = null;
95
+ // Retry up to 2 times on transport-closed / timeout errors
96
+ let lastErr = null;
102
97
  for (let attempt = 0; attempt < 3; attempt++) {
103
98
  try {
104
- const res = await this.callWithTimeout("tools/call", { name, arguments: args });
105
- if (res.error)
106
- throw new Error(res.error.message);
107
- const content = res.result?.content ?? [];
108
- return content
109
- .filter((c) => c.type === "text")
99
+ const res = await this.sdk.callTool({ name, arguments: args });
100
+ const content = (res?.content ?? []);
101
+ const text = content
102
+ .filter((c) => c.type === "text" && typeof c.text === "string")
110
103
  .map((c) => c.text)
111
104
  .join("\n");
105
+ if (res?.isError) {
106
+ throw new Error(text || `MCP tool '${name}' returned an error`);
107
+ }
108
+ return text;
112
109
  }
113
110
  catch (err) {
114
- lastError = err instanceof Error ? err : new Error(String(err));
115
- // Only retry on timeout or server death — not on application errors
116
- if (!lastError.message.includes("timeout") && !lastError.message.includes("exited")) {
117
- throw lastError;
111
+ lastErr = err instanceof Error ? err : new Error(String(err));
112
+ const msg = lastErr.message;
113
+ const retryable = /transport closed|timeout|ECONNRESET|stream closed|socket hang up/i.test(msg);
114
+ if (!retryable || attempt === 2)
115
+ throw lastErr;
116
+ try {
117
+ this.sdk = await this.reconnectImpl();
118
118
  }
119
- if (this.dead && attempt < 2) {
120
- try {
121
- const fresh = await McpClient.connect(this.cfg, this.timeoutMs);
122
- Object.assign(this, { proc: fresh.proc, dead: false, ready: true, nextId: 1, pending: new Map() });
123
- }
124
- catch {
125
- throw new Error(`MCP server '${this.name}' died and restart failed`);
126
- }
119
+ catch (reErr) {
120
+ throw new Error(`MCP '${this.name}' died and reconnect failed: ${reErr instanceof Error ? reErr.message : String(reErr)}`);
127
121
  }
128
122
  }
129
123
  }
130
- throw lastError ?? new Error(`MCP '${this.name}' call failed after retries`);
131
- }
132
- callWithTimeout(method, params) {
133
- return Promise.race([
134
- this.call(method, params),
135
- new Promise((_, reject) => setTimeout(() => reject(new Error(`MCP '${this.name}' call timeout (${this.timeoutMs}ms)`)), this.timeoutMs)),
136
- ]);
137
- }
138
- call(method, params) {
139
- return new Promise((resolve, reject) => {
140
- const id = this.nextId++;
141
- const req = { jsonrpc: "2.0", id, method, params };
142
- this.pending.set(id, { resolve, reject });
143
- this.proc.stdin.write(`${JSON.stringify(req)}\n`);
144
- });
124
+ throw lastErr ?? new Error(`MCP '${this.name}' callTool failed after retries`);
145
125
  }
146
126
  disconnect() {
147
- this.proc.kill();
127
+ void this.sdk.close?.();
148
128
  }
149
129
  }
150
130
  //# sourceMappingURL=client.js.map
@@ -0,0 +1,24 @@
1
+ import type { McpHttpConfig, McpServerConfig, McpSseConfig, McpStdioConfig } from "../harness/config.js";
2
+ /** Discriminated-union result: either a validated config or a human-readable error. */
3
+ export type NormalizeResult = {
4
+ kind: "ok";
5
+ cfg: NormalizedConfig;
6
+ } | {
7
+ kind: "error";
8
+ message: string;
9
+ };
10
+ export type NormalizedConfig = (McpStdioConfig & {
11
+ type: "stdio";
12
+ }) | (McpHttpConfig & {
13
+ inferredFromUrl?: boolean;
14
+ }) | (McpSseConfig & {
15
+ inferredFromUrl?: boolean;
16
+ });
17
+ /**
18
+ * Validate + normalize a raw MCP server config entry.
19
+ * - Infers missing `type` from `command`/`url`.
20
+ * - Interpolates ${ENV} in headers (http/sse only).
21
+ * - Returns {kind:"error"} with a reason for any invalid combination.
22
+ */
23
+ export declare function normalizeMcpConfig(raw: McpServerConfig, env: Record<string, string | undefined>): NormalizeResult;
24
+ //# sourceMappingURL=config-normalize.d.ts.map