@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 +14 -0
- package/dist/commands/index.d.ts +1 -1
- package/dist/commands/index.js +1 -1
- package/dist/commands/info.js +49 -7
- package/dist/commands/mcp-auth.d.ts +11 -0
- package/dist/commands/mcp-auth.js +57 -0
- package/dist/commands/types.d.ts +1 -1
- package/dist/components/REPL.js +10 -3
- package/dist/harness/config.d.ts +19 -3
- package/dist/harness/submit-handler.js +1 -1
- package/dist/mcp/client.d.ts +20 -10
- package/dist/mcp/client.js +89 -109
- 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/oauth-storage.d.ts +23 -0
- package/dist/mcp/oauth-storage.js +58 -0
- package/dist/mcp/oauth.d.ts +79 -0
- package/dist/mcp/oauth.js +257 -0
- package/dist/mcp/transport.d.ts +49 -0
- package/dist/mcp/transport.js +219 -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 +3 -1
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
|
```
|
package/dist/commands/index.d.ts
CHANGED
|
@@ -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
|
*/
|
package/dist/commands/index.js
CHANGED
|
@@ -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;
|
package/dist/commands/info.js
CHANGED
|
@@ -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
|
|
392
|
-
if (
|
|
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
|
|
399
|
-
|
|
400
|
-
|
|
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("
|
|
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
|
package/dist/commands/types.d.ts
CHANGED
|
@@ -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;
|
package/dist/components/REPL.js
CHANGED
|
@@ -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
|
-
|
|
409
|
-
|
|
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]);
|
package/dist/harness/config.d.ts
CHANGED
|
@@ -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
|
|
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 = [];
|
package/dist/mcp/client.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
package/dist/mcp/client.js
CHANGED
|
@@ -1,150 +1,130 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
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
|
-
|
|
7
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
});
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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.
|
|
69
|
-
return (res
|
|
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.
|
|
74
|
-
return (res
|
|
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.
|
|
82
|
-
|
|
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
|
-
|
|
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;
|
|
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.
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
|
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.
|
|
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
|