@zhijiewang/openharness 2.9.0 → 2.11.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,19 @@ 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
+
369
382
  **MCP Server Registry** — browse and install from a curated catalog:
370
383
 
371
384
  ```
@@ -2,20 +2,47 @@
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
+ };
21
+ export type McpSseConfig = McpCommonConfig & {
22
+ type: "sse";
23
+ url: string;
24
+ headers?: Record<string, string>;
25
+ };
26
+ export type McpServerConfig = McpStdioConfig | McpHttpConfig | McpSseConfig;
13
27
  export type HookDef = {
14
28
  command?: string;
15
29
  http?: string;
16
30
  prompt?: string;
17
31
  match?: string;
18
32
  timeout?: number;
33
+ /**
34
+ * When true (and this hook has a `command`), OH sends a JSON envelope
35
+ * `{event, ...context}` on stdin and parses a JSON response from stdout.
36
+ * Response shape (Claude Code compatible):
37
+ * { "decision": "allow" | "deny",
38
+ * "reason"?: string,
39
+ * "hookSpecificOutput"?: {...} }
40
+ *
41
+ * When false (default), OH passes context via `OH_EVENT` / `OH_TOOL_NAME`
42
+ * env vars and gates on the command's exit code (0 = allow). The env-var
43
+ * mode remains the default for backward compatibility.
44
+ */
45
+ jsonIO?: boolean;
19
46
  };
20
47
  export type HooksConfig = {
21
48
  sessionStart?: HookDef[];
@@ -141,6 +141,86 @@ function runCommandHookAsync(command, env, timeoutMs = 10_000) {
141
141
  });
142
142
  });
143
143
  }
144
+ /**
145
+ * Run a JSON-mode command hook (Claude Code convention).
146
+ *
147
+ * Sends `{event, ...context}` as JSON on stdin. Parses stdout as JSON
148
+ * `{ decision: "allow" | "deny", reason?: string, hookSpecificOutput?: any }`.
149
+ *
150
+ * Gating logic:
151
+ * - `decision: "deny"` → blocks (returns false).
152
+ * - `decision: "allow"` or omitted decision → allow (returns true).
153
+ * - Non-zero exit code → block.
154
+ * - Invalid/empty JSON on stdout → fall back to exit code (0 = allow).
155
+ * - Timeout or spawn error → block.
156
+ */
157
+ function runJsonIoHookAsync(command, env, event, ctx, timeoutMs = 10_000) {
158
+ return new Promise((resolve) => {
159
+ const proc = spawn(command, {
160
+ shell: true,
161
+ timeout: timeoutMs,
162
+ stdio: ["pipe", "pipe", "pipe"],
163
+ env,
164
+ });
165
+ let settled = false;
166
+ let stdoutBuf = "";
167
+ const timer = setTimeout(() => {
168
+ if (!settled) {
169
+ settled = true;
170
+ proc.kill();
171
+ resolve(false);
172
+ }
173
+ }, timeoutMs);
174
+ proc.stdout?.on("data", (chunk) => {
175
+ stdoutBuf += chunk.toString();
176
+ });
177
+ // Write the event + context JSON envelope to stdin then close it so the
178
+ // hook knows there's no more input coming.
179
+ try {
180
+ const payload = JSON.stringify({ event, ...ctx });
181
+ proc.stdin?.end(payload);
182
+ }
183
+ catch {
184
+ /* stdin already closed — ignore */
185
+ }
186
+ proc.on("close", (code) => {
187
+ if (settled)
188
+ return;
189
+ settled = true;
190
+ clearTimeout(timer);
191
+ // Non-zero exit is always a block, regardless of stdout.
192
+ if ((code ?? 1) !== 0) {
193
+ resolve(false);
194
+ return;
195
+ }
196
+ // Empty stdout → treat exit code as the signal (allow for exit 0).
197
+ if (!stdoutBuf.trim()) {
198
+ resolve(true);
199
+ return;
200
+ }
201
+ try {
202
+ const parsed = JSON.parse(stdoutBuf);
203
+ if (parsed.decision === "deny") {
204
+ resolve(false);
205
+ }
206
+ else {
207
+ resolve(true); // "allow" or omitted → allow
208
+ }
209
+ }
210
+ catch {
211
+ // Malformed JSON with a zero exit — fail closed conservatively.
212
+ resolve(false);
213
+ }
214
+ });
215
+ proc.on("error", () => {
216
+ if (!settled) {
217
+ settled = true;
218
+ clearTimeout(timer);
219
+ resolve(false);
220
+ }
221
+ });
222
+ });
223
+ }
144
224
  /** Run an HTTP hook. POSTs context as JSON, expects { allowed: true/false }. */
145
225
  async function runHttpHook(url, event, ctx, timeoutMs = 10_000) {
146
226
  try {
@@ -161,15 +241,63 @@ async function runHttpHook(url, event, ctx, timeoutMs = 10_000) {
161
241
  }
162
242
  }
163
243
  /**
164
- * Run a prompt hook. Uses LLM to make a yes/no decision.
244
+ * Run a prompt hook. Uses an LLM to make a yes/no allow/deny decision.
245
+ *
246
+ * The hook's `prompt:` field is the question posed to the model along with
247
+ * the event context. The response is parsed case-insensitively: responses
248
+ * starting with YES / ALLOW / TRUE / PASS / APPROVE allow; anything else
249
+ * (including explicit NO, DENY, errors, timeouts, empty) blocks.
165
250
  *
166
- * Currently a stub prompt hooks always allow because the hook system
167
- * runs outside the query loop and has no access to a Provider instance.
168
- * Full implementation requires passing a Provider via HookContext so the
169
- * hook can call provider.complete() with the prompt text.
251
+ * Fail-closed semantics: if the provider isn't reachable or the response
252
+ * can't be parsed, the hook denies. This matches command hooks (non-zero
253
+ * exit = deny) and HTTP hooks (network error = deny).
254
+ *
255
+ * Provider selection: reads `.oh/config.yaml` to get the configured provider
256
+ * and model. A separate provider instance is created per call — no caching,
257
+ * since hooks are rare and cold-start cost is negligible compared to the
258
+ * LLM call itself.
170
259
  */
171
- async function runPromptHook(_promptText, _ctx) {
172
- return true;
260
+ async function runPromptHook(promptText, ctx, timeoutMs = 10_000) {
261
+ try {
262
+ const cfg = readOhConfig();
263
+ if (!cfg)
264
+ return false; // no config → no provider → fail closed
265
+ const { createProvider } = (await import("../providers/index.js"));
266
+ const modelArg = cfg.model ? `${cfg.provider}/${cfg.model}` : cfg.provider;
267
+ const overrides = {};
268
+ if (cfg.apiKey)
269
+ overrides.apiKey = cfg.apiKey;
270
+ if (cfg.baseUrl)
271
+ overrides.baseUrl = cfg.baseUrl;
272
+ const { provider, model } = await createProvider(modelArg, overrides);
273
+ const systemPrompt = "You are a policy gate. Read the question and the event context. Answer with a single word: YES to allow, NO to deny. Do not explain unless asked.";
274
+ const userContent = [
275
+ `Question: ${promptText}`,
276
+ "",
277
+ "Event context:",
278
+ JSON.stringify({ event: ctx }, null, 2),
279
+ "",
280
+ "Answer (YES or NO):",
281
+ ].join("\n");
282
+ const { createUserMessage } = (await import("../types/message.js"));
283
+ const messages = [createUserMessage(userContent)];
284
+ // Race the completion against a hard timeout so a hung provider doesn't
285
+ // block the agent loop indefinitely.
286
+ const completion = await Promise.race([
287
+ provider.complete(messages, systemPrompt, undefined, model),
288
+ new Promise((resolve) => setTimeout(() => resolve(null), timeoutMs)),
289
+ ]);
290
+ if (!completion)
291
+ return false; // timeout → deny
292
+ const text = (completion.content ?? "").trim().toUpperCase();
293
+ if (!text)
294
+ return false;
295
+ // Accept multiple allow synonyms; default to deny on anything else.
296
+ return /^(YES|ALLOW|TRUE|PASS|APPROVE)\b/.test(text);
297
+ }
298
+ catch {
299
+ return false; // any error path → deny
300
+ }
173
301
  }
174
302
  // ── Hook Execution ──
175
303
  /** Execute a single hook definition. Returns true if allowed. */
@@ -177,6 +305,12 @@ async function executeHookDef(def, event, ctx) {
177
305
  const timeout = def.timeout ?? 10_000;
178
306
  if (def.command) {
179
307
  const env = buildEnv(event, ctx);
308
+ // JSON-mode (Claude Code convention): send `{event, ...ctx}` on stdin,
309
+ // parse `{decision}` from stdout. Env-var mode (legacy default): gate on
310
+ // exit code.
311
+ if (def.jsonIO) {
312
+ return runJsonIoHookAsync(def.command, env, event, ctx, timeout);
313
+ }
180
314
  const code = await runCommandHookAsync(def.command, env, timeout);
181
315
  return code === 0;
182
316
  }
@@ -206,14 +340,31 @@ export function emitHook(event, ctx = {}) {
206
340
  if (!matchesHook(def, ctx))
207
341
  continue;
208
342
  if (def.command) {
343
+ const input = def.jsonIO ? JSON.stringify({ event, ...ctx }) : undefined;
209
344
  const result = spawnSync(def.command, {
210
345
  shell: true,
211
346
  timeout: def.timeout ?? 10_000,
212
347
  stdio: "pipe",
213
348
  env,
349
+ input,
214
350
  });
215
351
  if (result.status !== 0 || result.error)
216
352
  return false;
353
+ // JSON mode: parse stdout for {decision: "deny"} → block. Allow on empty
354
+ // stdout (exit-code already gated above). Malformed JSON fails closed.
355
+ if (def.jsonIO) {
356
+ const out = result.stdout?.toString() ?? "";
357
+ if (out.trim()) {
358
+ try {
359
+ const parsed = JSON.parse(out);
360
+ if (parsed.decision === "deny")
361
+ return false;
362
+ }
363
+ catch {
364
+ return false;
365
+ }
366
+ }
367
+ }
217
368
  }
218
369
  // HTTP and prompt hooks for preToolUse are handled in emitHookAsync
219
370
  }
@@ -1,18 +1,25 @@
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
19
  static connect(cfg: McpServerConfig, timeoutMs?: number): Promise<McpClient>;
20
+ /** Test-only constructor. Not exported from the package's public API. */
21
+ static _forTesting(opts: ForTestingOptions): McpClient;
22
+ private defaultReconnect;
16
23
  listTools(): Promise<McpToolDef[]>;
17
24
  listResources(): Promise<Array<{
18
25
  uri: string;
@@ -21,8 +28,7 @@ export declare class McpClient {
21
28
  }>>;
22
29
  readResource(uri: string): Promise<string>;
23
30
  callTool(name: string, args: Record<string, unknown>): Promise<string>;
24
- private callWithTimeout;
25
- private call;
26
31
  disconnect(): void;
27
32
  }
33
+ export {};
28
34
  //# sourceMappingURL=client.d.ts.map
@@ -1,150 +1,97 @@
1
- import { spawn } from "node:child_process";
2
- import { createInterface } from "node:readline";
3
- import { safeEnv } from "../utils/safe-env.js";
1
+ import { normalizeMcpConfig } from "./config-normalize.js";
2
+ import { buildClient, connectWithFallback } from "./transport.js";
3
+ const DEFAULT_TIMEOUT_MS = 5_000;
4
4
  export class McpClient {
5
5
  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;
6
+ instructions = null;
7
+ sdk;
12
8
  cfg;
13
9
  timeoutMs;
14
- constructor(name, proc, cfg, timeoutMs) {
10
+ reconnectImpl;
11
+ constructor(name, cfg, sdk, timeoutMs, reconnect) {
15
12
  this.name = name;
16
- this.proc = proc;
17
13
  this.cfg = cfg;
14
+ this.sdk = sdk;
18
15
  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
- });
16
+ this.reconnectImpl = reconnect ?? (() => this.defaultReconnect());
17
+ const instr = sdk.getInstructions?.();
18
+ if (instr && typeof instr === "string") {
19
+ this.instructions = instr;
20
+ }
40
21
  }
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),
47
- });
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;
22
+ static async connect(cfg, timeoutMs = cfg.timeout ?? DEFAULT_TIMEOUT_MS) {
23
+ const normalized = normalizeMcpConfig(cfg, process.env);
24
+ if (normalized.kind === "error") {
25
+ throw new Error(normalized.message);
62
26
  }
63
- await client.call("notifications/initialized", {});
64
- client.ready = true;
65
- return client;
27
+ const sdk = await connectWithFallback(normalized.cfg, (c) => buildClient(c));
28
+ return new McpClient(cfg.name, cfg, sdk, timeoutMs);
29
+ }
30
+ /** Test-only constructor. Not exported from the package's public API. */
31
+ static _forTesting(opts) {
32
+ return new McpClient(opts.name, opts.cfg, opts.sdk, opts.timeoutMs, opts.reconnect);
33
+ }
34
+ async defaultReconnect() {
35
+ const normalized = normalizeMcpConfig(this.cfg, process.env);
36
+ if (normalized.kind === "error")
37
+ throw new Error(normalized.message);
38
+ return connectWithFallback(normalized.cfg, (c) => buildClient(c));
66
39
  }
67
40
  async listTools() {
68
- const res = await this.call("tools/list", {});
69
- return (res.result?.tools ?? []);
41
+ const res = await this.sdk.listTools();
42
+ return (res?.tools ?? []);
70
43
  }
71
44
  async listResources() {
72
45
  try {
73
- const res = await this.callWithTimeout("resources/list", {});
74
- return (res.result?.resources ?? []);
46
+ const res = await this.sdk.listResources();
47
+ return (res?.resources ?? []);
75
48
  }
76
49
  catch {
77
50
  return []; // Server may not support resources
78
51
  }
79
52
  }
80
53
  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 ?? [];
54
+ const res = await this.sdk.readResource({ uri });
55
+ const contents = res?.contents ?? [];
85
56
  return contents
86
- .filter((c) => c.text)
57
+ .filter((c) => typeof c.text === "string")
87
58
  .map((c) => c.text)
88
59
  .join("\n");
89
60
  }
90
61
  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;
62
+ // Retry up to 2 times on transport-closed / timeout errors
63
+ let lastErr = null;
102
64
  for (let attempt = 0; attempt < 3; attempt++) {
103
65
  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")
66
+ const res = await this.sdk.callTool({ name, arguments: args });
67
+ const content = (res?.content ?? []);
68
+ const text = content
69
+ .filter((c) => c.type === "text" && typeof c.text === "string")
110
70
  .map((c) => c.text)
111
71
  .join("\n");
72
+ if (res?.isError) {
73
+ throw new Error(text || `MCP tool '${name}' returned an error`);
74
+ }
75
+ return text;
112
76
  }
113
77
  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;
78
+ lastErr = err instanceof Error ? err : new Error(String(err));
79
+ const msg = lastErr.message;
80
+ const retryable = /transport closed|timeout|ECONNRESET|stream closed|socket hang up/i.test(msg);
81
+ if (!retryable || attempt === 2)
82
+ throw lastErr;
83
+ try {
84
+ this.sdk = await this.reconnectImpl();
118
85
  }
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
- }
86
+ catch (reErr) {
87
+ throw new Error(`MCP '${this.name}' died and reconnect failed: ${reErr instanceof Error ? reErr.message : String(reErr)}`);
127
88
  }
128
89
  }
129
90
  }
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
- });
91
+ throw lastErr ?? new Error(`MCP '${this.name}' callTool failed after retries`);
145
92
  }
146
93
  disconnect() {
147
- this.proc.kill();
94
+ void this.sdk.close?.();
148
95
  }
149
96
  }
150
97
  //# 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
@@ -0,0 +1,72 @@
1
+ const ENV_REF = /\$\{([A-Z_][A-Z0-9_]*)\}/g;
2
+ /** Replace ${VAR} references in `value` from `env`. Returns the new string or a missing-var name. */
3
+ function interpolate(value, env) {
4
+ let missing = null;
5
+ const out = value.replace(ENV_REF, (_match, varName) => {
6
+ const v = env[varName];
7
+ if (v === undefined) {
8
+ if (missing === null)
9
+ missing = varName;
10
+ return "";
11
+ }
12
+ return v;
13
+ });
14
+ if (missing !== null)
15
+ return { ok: false, missing };
16
+ return { ok: true, value: out };
17
+ }
18
+ function interpolateHeaders(headers, env) {
19
+ if (!headers)
20
+ return { ok: true, headers: undefined };
21
+ const out = {};
22
+ for (const [k, v] of Object.entries(headers)) {
23
+ const r = interpolate(v, env);
24
+ if (!r.ok)
25
+ return { ok: false, missing: r.missing };
26
+ out[k] = r.value;
27
+ }
28
+ return { ok: true, headers: out };
29
+ }
30
+ /**
31
+ * Validate + normalize a raw MCP server config entry.
32
+ * - Infers missing `type` from `command`/`url`.
33
+ * - Interpolates ${ENV} in headers (http/sse only).
34
+ * - Returns {kind:"error"} with a reason for any invalid combination.
35
+ */
36
+ export function normalizeMcpConfig(raw, env) {
37
+ const hasCommand = "command" in raw && !!raw.command;
38
+ const hasUrl = "url" in raw && !!raw.url;
39
+ if (hasCommand && hasUrl) {
40
+ return { kind: "error", message: `MCP '${raw.name}': config sets both 'command' and 'url'` };
41
+ }
42
+ const declaredType = raw.type;
43
+ const effectiveType = declaredType ?? (hasCommand ? "stdio" : hasUrl ? "http" : undefined);
44
+ if (!effectiveType) {
45
+ return { kind: "error", message: `MCP '${raw.name}': must set 'command' (stdio) or 'url' (http/sse)` };
46
+ }
47
+ if (effectiveType === "stdio") {
48
+ if (!hasCommand) {
49
+ return { kind: "error", message: `MCP '${raw.name}': type='stdio' requires 'command'` };
50
+ }
51
+ return { kind: "ok", cfg: { ...raw, type: "stdio" } };
52
+ }
53
+ // http or sse
54
+ if (!hasUrl) {
55
+ return { kind: "error", message: `MCP '${raw.name}': type='${effectiveType}' requires 'url'` };
56
+ }
57
+ const headers = raw.headers;
58
+ const interp = interpolateHeaders(headers, env);
59
+ if (!interp.ok) {
60
+ return {
61
+ kind: "error",
62
+ message: `MCP '${raw.name}': env var '${interp.missing}' referenced in headers is not set`,
63
+ };
64
+ }
65
+ const inferred = declaredType === undefined;
66
+ const base = { ...raw, type: effectiveType, headers: interp.headers };
67
+ return {
68
+ kind: "ok",
69
+ cfg: inferred ? { ...base, inferredFromUrl: true } : base,
70
+ };
71
+ }
72
+ //# sourceMappingURL=config-normalize.js.map
@@ -3,10 +3,34 @@ import { McpClient } from "./client.js";
3
3
  import { DeferredMcpTool } from "./DeferredMcpTool.js";
4
4
  import { McpTool } from "./McpTool.js";
5
5
  const connectedClients = [];
6
+ let exitHandlerInstalled = false;
7
+ function installExitHandler() {
8
+ if (exitHandlerInstalled)
9
+ return;
10
+ exitHandlerInstalled = true;
11
+ const handler = () => {
12
+ try {
13
+ disconnectMcpClients();
14
+ }
15
+ catch {
16
+ /* shutdown best-effort */
17
+ }
18
+ };
19
+ process.once("exit", handler);
20
+ process.once("SIGINT", () => {
21
+ handler();
22
+ process.exit(130);
23
+ });
24
+ process.once("SIGTERM", () => {
25
+ handler();
26
+ process.exit(143);
27
+ });
28
+ }
6
29
  /** Threshold: servers with more tools than this use deferred loading */
7
30
  const DEFERRED_THRESHOLD = 10;
8
31
  /** Load MCP tools from .oh/config.yaml mcpServers list. Returns empty array if none configured. */
9
32
  export async function loadMcpTools() {
33
+ installExitHandler();
10
34
  const cfg = readOhConfig();
11
35
  const servers = cfg?.mcpServers ?? [];
12
36
  if (servers.length === 0)
@@ -0,0 +1,38 @@
1
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
+ import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
3
+ import type { NormalizedConfig } from "./config-normalize.js";
4
+ export declare class RemoteAuthRequiredError extends Error {
5
+ readonly serverName: string;
6
+ readonly wwwAuthenticate: string | undefined;
7
+ constructor(serverName: string, wwwAuthenticate: string | undefined);
8
+ }
9
+ export declare class UnreachableError extends Error {
10
+ readonly serverName: string;
11
+ readonly cause: unknown;
12
+ constructor(serverName: string, cause: unknown);
13
+ }
14
+ export declare class ProtocolError extends Error {
15
+ readonly serverName: string;
16
+ readonly cause: unknown;
17
+ constructor(serverName: string, cause: unknown);
18
+ }
19
+ /**
20
+ * Construct an SDK Transport for a normalized config.
21
+ * Does NOT call .start() — caller (Client.connect) handles that.
22
+ */
23
+ export declare function buildTransport(cfg: NormalizedConfig): Promise<Transport>;
24
+ /**
25
+ * Connect to an MCP server, with auto-fallback from Streamable HTTP to
26
+ * legacy SSE when the config's type was INFERRED from url (not explicit).
27
+ *
28
+ * `doConnect` is the side-effecting step — build/connect a transport and
29
+ * return the opaque client. Kept injectable for tests; production call-site
30
+ * wires it to `buildClient` (Task 7).
31
+ */
32
+ export declare function connectWithFallback<T>(cfg: NormalizedConfig, doConnect: (cfg: NormalizedConfig) => Promise<T>): Promise<T>;
33
+ /**
34
+ * Build a connected SDK Client for a normalized config.
35
+ * Maps connect-time errors into OH's typed error taxonomy.
36
+ */
37
+ export declare function buildClient(cfg: NormalizedConfig): Promise<Client>;
38
+ //# sourceMappingURL=transport.d.ts.map
@@ -0,0 +1,159 @@
1
+ import { createRequire } from "node:module";
2
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3
+ import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
4
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
5
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
6
+ const pkg = createRequire(import.meta.url)("../../package.json");
7
+ export class RemoteAuthRequiredError extends Error {
8
+ serverName;
9
+ wwwAuthenticate;
10
+ constructor(serverName, wwwAuthenticate) {
11
+ super(`MCP server '${serverName}' requires authentication. ` +
12
+ `Add headers.Authorization to your config (OAuth flow is not yet supported).`);
13
+ this.name = "RemoteAuthRequiredError";
14
+ this.serverName = serverName;
15
+ this.wwwAuthenticate = wwwAuthenticate;
16
+ }
17
+ }
18
+ export class UnreachableError extends Error {
19
+ serverName;
20
+ cause;
21
+ constructor(serverName, cause) {
22
+ const causeMsg = cause instanceof Error ? cause.message : String(cause);
23
+ super(`MCP server '${serverName}' unreachable: ${causeMsg}`);
24
+ this.name = "UnreachableError";
25
+ this.serverName = serverName;
26
+ this.cause = cause;
27
+ }
28
+ }
29
+ export class ProtocolError extends Error {
30
+ serverName;
31
+ cause;
32
+ constructor(serverName, cause) {
33
+ const causeMsg = cause instanceof Error ? cause.message : String(cause);
34
+ super(`MCP server '${serverName}' protocol error: ${causeMsg}`);
35
+ this.name = "ProtocolError";
36
+ this.serverName = serverName;
37
+ this.cause = cause;
38
+ }
39
+ }
40
+ /**
41
+ * Construct an SDK Transport for a normalized config.
42
+ * Does NOT call .start() — caller (Client.connect) handles that.
43
+ */
44
+ export async function buildTransport(cfg) {
45
+ if (cfg.type === "stdio") {
46
+ return new StdioClientTransport({
47
+ command: cfg.command,
48
+ args: cfg.args,
49
+ env: cfg.env,
50
+ });
51
+ }
52
+ if (cfg.type === "http") {
53
+ return new StreamableHTTPClientTransport(new URL(cfg.url), {
54
+ requestInit: cfg.headers ? { headers: cfg.headers } : undefined,
55
+ });
56
+ }
57
+ if (cfg.type === "sse") {
58
+ return new SSEClientTransport(new URL(cfg.url), {
59
+ requestInit: cfg.headers ? { headers: cfg.headers } : undefined,
60
+ });
61
+ }
62
+ throw new Error(`unknown transport type: ${cfg.type}`);
63
+ }
64
+ /**
65
+ * Does this error indicate "try the legacy SSE transport instead"?
66
+ * Yes for 4xx OTHER than 401-with-WWW-Authenticate (which is a real auth challenge).
67
+ */
68
+ function isFallbackCandidate(err) {
69
+ const code = err?.code;
70
+ if (typeof code !== "number")
71
+ return false;
72
+ if (code < 400 || code >= 500)
73
+ return false;
74
+ if (code === 401) {
75
+ const www = err?.headers?.["www-authenticate"];
76
+ if (typeof www === "string" && www.length > 0)
77
+ return false;
78
+ }
79
+ return true;
80
+ }
81
+ function extractWwwAuthenticate(err) {
82
+ const code = err?.code;
83
+ if (code !== 401)
84
+ return undefined;
85
+ const www = err?.headers?.["www-authenticate"];
86
+ return typeof www === "string" ? www : undefined;
87
+ }
88
+ /**
89
+ * Connect to an MCP server, with auto-fallback from Streamable HTTP to
90
+ * legacy SSE when the config's type was INFERRED from url (not explicit).
91
+ *
92
+ * `doConnect` is the side-effecting step — build/connect a transport and
93
+ * return the opaque client. Kept injectable for tests; production call-site
94
+ * wires it to `buildClient` (Task 7).
95
+ */
96
+ export async function connectWithFallback(cfg, doConnect) {
97
+ try {
98
+ return await doConnect(cfg);
99
+ }
100
+ catch (err) {
101
+ // Auth challenge → surface immediately, never fall back
102
+ const www = extractWwwAuthenticate(err);
103
+ if (www !== undefined)
104
+ throw new RemoteAuthRequiredError(cfg.name, www);
105
+ // Explicit type → surface as-is
106
+ const inferred = cfg.inferredFromUrl === true;
107
+ if (!inferred)
108
+ throw err;
109
+ // Only http-was-inferred-first falls back to sse; other shapes surface
110
+ if (cfg.type !== "http")
111
+ throw err;
112
+ if (!isFallbackCandidate(err))
113
+ throw err;
114
+ // Log + retry
115
+ // biome-ignore lint/suspicious/noConsole: user-facing diagnostic
116
+ console.warn(`[mcp] ${cfg.name}: Streamable HTTP failed (${err.message}); trying legacy SSE`);
117
+ const sseCfg = { ...cfg, type: "sse" };
118
+ return await doConnect(sseCfg);
119
+ }
120
+ }
121
+ const DEFAULT_TIMEOUT_MS = 5_000;
122
+ const CLIENT_INFO = { name: "openharness", version: pkg.version };
123
+ /**
124
+ * Build a connected SDK Client for a normalized config.
125
+ * Maps connect-time errors into OH's typed error taxonomy.
126
+ */
127
+ export async function buildClient(cfg) {
128
+ const transport = await buildTransport(cfg);
129
+ const client = new Client(CLIENT_INFO, { capabilities: {} });
130
+ const timeoutMs = cfg.timeout ?? DEFAULT_TIMEOUT_MS;
131
+ let timer = null;
132
+ 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
+ ]);
139
+ return client;
140
+ }
141
+ catch (err) {
142
+ // Leave RemoteAuthRequiredError / UnreachableError / ProtocolError as-is
143
+ if (err instanceof RemoteAuthRequiredError || err instanceof UnreachableError || err instanceof ProtocolError) {
144
+ throw err;
145
+ }
146
+ // Network-shaped errors (DNS, TCP, TLS, timeout) → Unreachable
147
+ const msg = err?.message ?? String(err);
148
+ if (/timeout|ECONNREFUSED|ENOTFOUND|ETIMEDOUT|EAI_AGAIN|network|fetch failed/i.test(msg)) {
149
+ throw new UnreachableError(cfg.name, err);
150
+ }
151
+ // Otherwise protocol-shaped
152
+ throw new ProtocolError(cfg.name, err);
153
+ }
154
+ finally {
155
+ if (timer !== null)
156
+ clearTimeout(timer);
157
+ }
158
+ }
159
+ //# sourceMappingURL=transport.js.map
@@ -1,19 +1,4 @@
1
- /** Minimal MCP protocol types (JSON-RPC 2.0 over stdio) */
2
- export interface JsonRpcRequest {
3
- jsonrpc: "2.0";
4
- id: number;
5
- method: string;
6
- params?: unknown;
7
- }
8
- export interface JsonRpcResponse {
9
- jsonrpc: "2.0";
10
- id: number;
11
- result?: unknown;
12
- error?: {
13
- code: number;
14
- message: string;
15
- };
16
- }
1
+ /** MCP tool definition as returned by `tools/list`. */
17
2
  export interface McpToolDef {
18
3
  name: string;
19
4
  description?: string;
package/dist/mcp/types.js CHANGED
@@ -1,3 +1,2 @@
1
- /** Minimal MCP protocol types (JSON-RPC 2.0 over stdio) */
2
1
  export {};
3
2
  //# sourceMappingURL=types.js.map
@@ -12,16 +12,16 @@ declare const inputSchema: z.ZodObject<{
12
12
  action: "search" | "save" | "list";
13
13
  content?: string | undefined;
14
14
  type?: "user" | "convention" | "preference" | "project" | "debugging" | "feedback" | "reference" | undefined;
15
- description?: string | undefined;
16
15
  name?: string | undefined;
16
+ description?: string | undefined;
17
17
  global?: boolean | undefined;
18
18
  query?: string | undefined;
19
19
  }, {
20
20
  action: "search" | "save" | "list";
21
21
  content?: string | undefined;
22
22
  type?: "user" | "convention" | "preference" | "project" | "debugging" | "feedback" | "reference" | undefined;
23
- description?: string | undefined;
24
23
  name?: string | undefined;
24
+ description?: string | undefined;
25
25
  global?: boolean | undefined;
26
26
  query?: string | undefined;
27
27
  }>;
@@ -12,7 +12,7 @@ declare const inputSchema: z.ZodObject<{
12
12
  addBlockedBy: z.ZodOptional<z.ZodArray<z.ZodNumber, "many">>;
13
13
  }, "strip", z.ZodTypeAny, {
14
14
  taskId: number;
15
- status?: "completed" | "pending" | "cancelled" | "in_progress" | "deleted" | undefined;
15
+ status?: "completed" | "cancelled" | "pending" | "in_progress" | "deleted" | undefined;
16
16
  description?: string | undefined;
17
17
  metadata?: Record<string, unknown> | undefined;
18
18
  subject?: string | undefined;
@@ -22,7 +22,7 @@ declare const inputSchema: z.ZodObject<{
22
22
  addBlockedBy?: number[] | undefined;
23
23
  }, {
24
24
  taskId: number;
25
- status?: "completed" | "pending" | "cancelled" | "in_progress" | "deleted" | undefined;
25
+ status?: "completed" | "cancelled" | "pending" | "in_progress" | "deleted" | undefined;
26
26
  description?: string | undefined;
27
27
  metadata?: Record<string, unknown> | undefined;
28
28
  subject?: string | undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhijiewang/openharness",
3
- "version": "2.9.0",
3
+ "version": "2.11.0",
4
4
  "description": "Open-source terminal coding agent. Works with any LLM.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -36,6 +36,7 @@
36
36
  "start": "node dist/main.js"
37
37
  },
38
38
  "dependencies": {
39
+ "@modelcontextprotocol/sdk": "^1.29.0",
39
40
  "better-sqlite3": "^12.9.0",
40
41
  "chalk": "^5.4.1",
41
42
  "commander": "^13.0.0",