@zhijiewang/openharness 2.10.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 +13 -0
- package/dist/harness/config.d.ts +17 -3
- package/dist/mcp/client.d.ts +15 -9
- package/dist/mcp/client.js +57 -110
- package/dist/mcp/config-normalize.d.ts +24 -0
- package/dist/mcp/config-normalize.js +72 -0
- package/dist/mcp/loader.js +24 -0
- package/dist/mcp/transport.d.ts +38 -0
- package/dist/mcp/transport.js +159 -0
- package/dist/mcp/types.d.ts +1 -16
- package/dist/mcp/types.js +0 -1
- package/dist/tools/TaskUpdateTool/index.d.ts +2 -2
- package/package.json +2 -1
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
|
```
|
package/dist/harness/config.d.ts
CHANGED
|
@@ -2,14 +2,28 @@
|
|
|
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
|
|
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;
|
package/dist/mcp/client.d.ts
CHANGED
|
@@ -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
|
-
|
|
6
|
-
private
|
|
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
|
package/dist/mcp/client.js
CHANGED
|
@@ -1,150 +1,97 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
|
|
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
|
-
|
|
7
|
-
|
|
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
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
64
|
-
|
|
65
|
-
|
|
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.
|
|
69
|
-
return (res
|
|
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.
|
|
74
|
-
return (res
|
|
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.
|
|
82
|
-
|
|
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
|
-
|
|
92
|
-
|
|
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.
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
120
|
-
|
|
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
|
|
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.
|
|
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
|
package/dist/mcp/loader.js
CHANGED
|
@@ -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
|
package/dist/mcp/types.d.ts
CHANGED
|
@@ -1,19 +1,4 @@
|
|
|
1
|
-
/**
|
|
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
|
@@ -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" | "
|
|
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" | "
|
|
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.
|
|
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",
|