privateer-agent 0.1.1 → 0.3.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.
Files changed (106) hide show
  1. package/README.md +143 -398
  2. package/bin/privateer-tui +86 -0
  3. package/bin/privateer.mjs +17 -6
  4. package/bin/pv +28 -0
  5. package/package.json +26 -23
  6. package/src/auth/privateer.ts +128 -1
  7. package/src/boot.ts +43 -0
  8. package/src/bridge/engineAdapter.ts +182 -0
  9. package/src/cli/chat.ts +243 -0
  10. package/src/config/paths.ts +24 -44
  11. package/src/config/permissionMode.ts +5 -0
  12. package/src/crypto/outboxSeal.ts +61 -0
  13. package/src/daemon/index.ts +405 -0
  14. package/src/daemon/ipc.ts +127 -0
  15. package/src/engine/errors.ts +95 -45
  16. package/src/engine/router.ts +11 -165
  17. package/src/ext/permissionGate.ts +216 -0
  18. package/src/main.ts +32 -0
  19. package/src/permissions/classify.ts +172 -0
  20. package/src/permissions/gate.ts +13 -11
  21. package/src/permissions/mode.ts +9 -1
  22. package/src/permissions/{uiGate.ts → modeGate.ts} +22 -6
  23. package/src/providers/account.ts +170 -0
  24. package/src/providers/catalog.ts +73 -61
  25. package/src/providers/genModelsJson.ts +97 -0
  26. package/src/remote/relayClient.ts +91 -4
  27. package/src/remote/remoteBridge.ts +152 -0
  28. package/src/routines/cron.ts +109 -0
  29. package/src/routines/delivery.ts +184 -0
  30. package/src/routines/schema.ts +84 -0
  31. package/src/routines/store.ts +248 -0
  32. package/src/routines/toolSelect.ts +50 -0
  33. package/src/routines/trigger.ts +41 -0
  34. package/src/session.ts +84 -251
  35. package/src/tools/routine.ts +129 -0
  36. package/src/tools/saveAttachment.ts +39 -42
  37. package/src/tools/sendFile.ts +75 -0
  38. package/src/util/attachmentStore.ts +18 -35
  39. package/src/util/redact.ts +33 -3
  40. package/LICENSE +0 -21
  41. package/src/agents/loader.ts +0 -49
  42. package/src/commands/custom.ts +0 -75
  43. package/src/commands/registry.ts +0 -499
  44. package/src/components/AgentGroupView.tsx +0 -104
  45. package/src/components/App.tsx +0 -1420
  46. package/src/components/ApprovalPrompt.tsx +0 -38
  47. package/src/components/Banner.tsx +0 -76
  48. package/src/components/Markdown.tsx +0 -183
  49. package/src/components/ModeHint.tsx +0 -40
  50. package/src/components/ModelPicker.tsx +0 -269
  51. package/src/components/Onboarding.tsx +0 -203
  52. package/src/components/PlanConfirm.tsx +0 -37
  53. package/src/components/PrivateerLogin.tsx +0 -109
  54. package/src/components/PromptInput.tsx +0 -602
  55. package/src/components/RewindPicker.tsx +0 -69
  56. package/src/components/Root.tsx +0 -95
  57. package/src/components/SessionPicker.tsx +0 -64
  58. package/src/components/StatusBar.tsx +0 -131
  59. package/src/components/TodoPanel.tsx +0 -36
  60. package/src/components/ToolCallView.tsx +0 -109
  61. package/src/components/Transcript.tsx +0 -203
  62. package/src/components/figures.ts +0 -14
  63. package/src/components/promptModel.ts +0 -73
  64. package/src/components/spinnerVerbs.ts +0 -46
  65. package/src/components/theme.ts +0 -55
  66. package/src/components/types.ts +0 -34
  67. package/src/components/useTeeShield.ts +0 -104
  68. package/src/components/useTerminalWidth.ts +0 -24
  69. package/src/components/useZdrShield.ts +0 -126
  70. package/src/config/load.ts +0 -115
  71. package/src/config/schema.ts +0 -94
  72. package/src/context/outputStyles.ts +0 -42
  73. package/src/context/projectInfo.ts +0 -59
  74. package/src/context/systemPrompt.ts +0 -167
  75. package/src/engine/QueryEngine.ts +0 -399
  76. package/src/hooks/engine.ts +0 -155
  77. package/src/main.tsx +0 -167
  78. package/src/mcp/client.ts +0 -236
  79. package/src/mcp/oauth.ts +0 -245
  80. package/src/memory/auto.ts +0 -146
  81. package/src/memory/checkpoints.ts +0 -227
  82. package/src/memory/store.ts +0 -127
  83. package/src/providers/attestation.ts +0 -149
  84. package/src/providers/capabilities.ts +0 -104
  85. package/src/providers/models.ts +0 -183
  86. package/src/providers/registry.ts +0 -71
  87. package/src/providers/resolve.ts +0 -78
  88. package/src/tools/bash.ts +0 -98
  89. package/src/tools/context.ts +0 -114
  90. package/src/tools/edit.ts +0 -67
  91. package/src/tools/exec.ts +0 -60
  92. package/src/tools/glob.ts +0 -39
  93. package/src/tools/grep.ts +0 -86
  94. package/src/tools/index.ts +0 -69
  95. package/src/tools/memory.ts +0 -53
  96. package/src/tools/processRegistry.ts +0 -77
  97. package/src/tools/read.ts +0 -42
  98. package/src/tools/task.ts +0 -52
  99. package/src/tools/todo.ts +0 -36
  100. package/src/tools/todoStore.ts +0 -31
  101. package/src/tools/walk.ts +0 -44
  102. package/src/tools/web.ts +0 -145
  103. package/src/tools/write.ts +0 -40
  104. package/src/util/images.ts +0 -356
  105. package/src/util/limit.ts +0 -32
  106. package/src/version.ts +0 -13
@@ -1,155 +0,0 @@
1
- import type { ToolSet } from "ai";
2
- import { exec } from "../tools/exec.ts";
3
-
4
- // A single hook: a shell command, optionally gated by a regex matched against the
5
- // tool name (tool events only). Loaded from settings under the `hooks` key.
6
- export interface HookDef {
7
- matcher?: string;
8
- command: string;
9
- }
10
-
11
- export type HookEvent = "PreToolUse" | "PostToolUse" | "UserPromptSubmit" | "Stop";
12
- export type HooksConfig = Partial<Record<HookEvent, HookDef[]>>;
13
-
14
- // Aggregated result of firing an event's hooks.
15
- export interface HookOutcome {
16
- block?: boolean; // PreToolUse / UserPromptSubmit may veto
17
- reason?: string;
18
- additionalContext?: string; // UserPromptSubmit may inject extra context
19
- }
20
-
21
- const HOOK_TIMEOUT_MS = 30_000;
22
- const KNOWN_EVENTS: HookEvent[] = ["PreToolUse", "PostToolUse", "UserPromptSubmit", "Stop"];
23
-
24
- // Parse the loosely-typed `hooks` section of settings into a validated HooksConfig.
25
- export function loadHooks(raw: unknown): HooksConfig {
26
- if (typeof raw !== "object" || raw === null) return {};
27
- const out: HooksConfig = {};
28
- for (const event of KNOWN_EVENTS) {
29
- const entries = (raw as Record<string, unknown>)[event];
30
- if (!Array.isArray(entries)) continue;
31
- const defs: HookDef[] = [];
32
- for (const e of entries) {
33
- if (e && typeof e === "object" && typeof (e as any).command === "string") {
34
- defs.push({ matcher: (e as any).matcher, command: (e as any).command });
35
- }
36
- }
37
- if (defs.length) out[event] = defs;
38
- }
39
- return out;
40
- }
41
-
42
- function matches(def: HookDef, toolName?: string): boolean {
43
- if (!def.matcher) return true;
44
- if (toolName === undefined) return true;
45
- try {
46
- return new RegExp(def.matcher).test(toolName);
47
- } catch {
48
- return false; // invalid regex → don't match
49
- }
50
- }
51
-
52
- // Fire one hook with a JSON payload on stdin; interpret its result. A hook blocks by
53
- // exiting with code 2 (reason from stderr) or printing JSON with decision "block"/"deny".
54
- // It may inject context via { "additionalContext": "..." }.
55
- async function fireHook(def: HookDef, payload: unknown, cwd: string): Promise<HookOutcome> {
56
- const res = await exec(def.command, [], {
57
- cwd,
58
- timeoutMs: HOOK_TIMEOUT_MS,
59
- shell: true,
60
- input: JSON.stringify(payload),
61
- });
62
- if (res.code === 2) return { block: true, reason: res.stderr.trim() || "blocked by hook" };
63
- const stdout = res.stdout.trim();
64
- if (stdout.startsWith("{")) {
65
- try {
66
- const out = JSON.parse(stdout) as Record<string, unknown>;
67
- const decision = (out.decision ?? out.permissionDecision) as string | undefined;
68
- return {
69
- block: decision === "block" || decision === "deny",
70
- reason: (out.reason ?? out.permissionDecisionReason) as string | undefined,
71
- additionalContext: out.additionalContext as string | undefined,
72
- };
73
- } catch {
74
- /* not JSON → no control signal */
75
- }
76
- }
77
- return {};
78
- }
79
-
80
- // Runs the configured hooks for each lifecycle event.
81
- export class HookRunner {
82
- constructor(
83
- private readonly hooks: HooksConfig,
84
- private readonly cwd: string,
85
- ) {}
86
-
87
- has(event: HookEvent): boolean {
88
- return (this.hooks[event]?.length ?? 0) > 0;
89
- }
90
-
91
- config(): HooksConfig {
92
- return this.hooks;
93
- }
94
-
95
- // Fire tool-event hooks (Pre/PostToolUse). The first hook to block wins.
96
- async tool(
97
- event: "PreToolUse" | "PostToolUse",
98
- toolName: string,
99
- payload: { input: unknown; output?: unknown },
100
- ): Promise<HookOutcome> {
101
- const defs = (this.hooks[event] ?? []).filter((d) => matches(d, toolName));
102
- let merged: HookOutcome = {};
103
- for (const def of defs) {
104
- const outcome = await fireHook(def, { event, tool: toolName, ...payload, cwd: this.cwd }, this.cwd);
105
- if (outcome.block) return outcome;
106
- merged = { ...merged, ...outcome };
107
- }
108
- return merged;
109
- }
110
-
111
- // Fire UserPromptSubmit hooks; a block vetoes the turn, additionalContext is merged.
112
- async prompt(text: string): Promise<HookOutcome> {
113
- const defs = this.hooks.UserPromptSubmit ?? [];
114
- let context = "";
115
- for (const def of defs) {
116
- const outcome = await fireHook(def, { event: "UserPromptSubmit", prompt: text, cwd: this.cwd }, this.cwd);
117
- if (outcome.block) return outcome;
118
- if (outcome.additionalContext) context += (context ? "\n" : "") + outcome.additionalContext;
119
- }
120
- return context ? { additionalContext: context } : {};
121
- }
122
-
123
- // Fire Stop hooks (turn finished); fire-and-report, no control flow.
124
- async stop(): Promise<void> {
125
- for (const def of this.hooks.Stop ?? []) {
126
- await fireHook(def, { event: "Stop", cwd: this.cwd }, this.cwd);
127
- }
128
- }
129
- }
130
-
131
- // Wrap each tool's execute with Pre/PostToolUse hooks. A blocking PreToolUse hook
132
- // short-circuits the call, returning the reason so the model can adapt. No-op when no
133
- // tool hooks are configured.
134
- export function wrapToolsWithHooks(tools: ToolSet, runner: HookRunner): ToolSet {
135
- if (!runner.has("PreToolUse") && !runner.has("PostToolUse")) return tools;
136
- const wrapped: ToolSet = {};
137
- for (const [name, t] of Object.entries(tools)) {
138
- const orig = (t as any).execute;
139
- if (typeof orig !== "function") {
140
- wrapped[name] = t;
141
- continue;
142
- }
143
- wrapped[name] = {
144
- ...(t as any),
145
- execute: async (input: unknown, options: unknown) => {
146
- const pre = await runner.tool("PreToolUse", name, { input });
147
- if (pre.block) return `Blocked by PreToolUse hook: ${pre.reason ?? "no reason given"}`;
148
- const output = await orig(input, options);
149
- await runner.tool("PostToolUse", name, { input, output });
150
- return output;
151
- },
152
- } as ToolSet[string];
153
- }
154
- return wrapped;
155
- }
package/src/main.tsx DELETED
@@ -1,167 +0,0 @@
1
- import React from "react";
2
- import { render } from "ink";
3
- import { Command } from "commander";
4
- import { Root } from "./components/Root.tsx";
5
- import { NAME, VERSION, DESCRIPTION } from "./version.ts";
6
- import { loadConfig } from "./config/load.ts";
7
- import { createSession } from "./session.ts";
8
- import { loadLatest, loadSession } from "./memory/store.ts";
9
- import { configuredProviders } from "./providers/resolve.ts";
10
- import { describeError } from "./engine/errors.ts";
11
-
12
- // Set while Ink owns the screen. A stray unhandled rejection while the TUI is up
13
- // must NOT reach stdout/stderr — Node's default printer dumps the whole error
14
- // object (the request body included) unredacted and scrambles the render. The
15
- // underlying error has already surfaced cleanly via the engine's `error` event,
16
- // so here we just swallow the duplicate.
17
- let tuiActive = false;
18
-
19
- process.on("unhandledRejection", (reason) => {
20
- if (tuiActive) return;
21
- const d = describeError(reason);
22
- process.stderr.write(`\nError: ${d.message}${d.hint ? `\n${d.hint}` : ""}\n`);
23
- process.exitCode = 1;
24
- });
25
-
26
- interface CliOptions {
27
- print?: boolean;
28
- model?: string;
29
- cwd?: string;
30
- dangerouslySkipPermissions?: boolean;
31
- // Commander treats `--no-quarter` as a negatable boolean: `quarter` is true by
32
- // default and becomes false when the flag is passed.
33
- quarter?: boolean;
34
- continue?: boolean;
35
- resume?: string;
36
- onboard?: boolean;
37
- // Commander negatable boolean: `confine` is true by default, false with --no-confine.
38
- confine?: boolean;
39
- }
40
-
41
- const DEFAULT_MODEL = "anthropic:claude-opus-4-8";
42
-
43
- async function main() {
44
- const program = new Command();
45
-
46
- program
47
- .name(NAME)
48
- .description(DESCRIPTION)
49
- .version(VERSION, "-v, --version")
50
- .argument("[prompt...]", "prompt to send (with -p, runs headless)")
51
- .option("-p, --print", "print mode: run headless and write the answer to stdout")
52
- .option("-m, --model <provider:model>", "model to use, e.g. openrouter:anthropic/claude-opus-4.8")
53
- .option("-C, --cwd <dir>", "working directory")
54
- .option("--no-confine", "let the agent read/edit outside the working directory without prompting")
55
- .option("--dangerously-skip-permissions", "auto-approve all tool actions (bypass mode)")
56
- .option("--no-quarter", "auto-approve all tool actions, taking no prisoners (bypass mode)")
57
- .option("-c, --continue", "resume the most recent session in this directory")
58
- .option("-r, --resume <id>", "resume a specific session by id (printed on exit)")
59
- .option("--onboard", "run the provider/key setup flow")
60
- .action(async (promptParts: string[], options: CliOptions) => {
61
- try {
62
- if (options.cwd) process.chdir(options.cwd);
63
- const config = loadConfig();
64
- if (options.dangerouslySkipPermissions || options.quarter === false)
65
- config.permissionMode = "bypass";
66
- // --no-confine turns off the working-directory boundary for this run.
67
- if (options.confine === false) config.confineToCwd = false;
68
- // --resume <id> loads a specific session (the id printed on a prior exit);
69
- // --continue loads the most recent one.
70
- const resume = options.resume
71
- ? loadSession(process.cwd(), options.resume)
72
- : options.continue
73
- ? loadLatest(process.cwd())
74
- : null;
75
- if (options.resume && !resume) {
76
- process.stderr.write(`No session "${options.resume}" found in this directory.\n`);
77
- }
78
- const modelSpec = options.model ?? resume?.modelSpec ?? config.defaultModel ?? DEFAULT_MODEL;
79
-
80
- if (options.print) {
81
- await runPrint(modelSpec, promptParts.join(" ").trim(), config.confineToCwd);
82
- return;
83
- }
84
-
85
- // First-run onboarding: no provider has credentials yet (and we're not
86
- // resuming). Also forceable with --onboard.
87
- const noProviderReady = !configuredProviders(config).some((p) => p.ready);
88
- const startInOnboarding = Boolean(options.onboard) || (!resume && noProviderReady);
89
-
90
- // Interactive TUI.
91
- tuiActive = true;
92
- const { waitUntilExit } = render(
93
- <Root
94
- config={config}
95
- modelSpec={modelSpec}
96
- cwd={process.cwd()}
97
- resume={resume}
98
- startInOnboarding={startInOnboarding}
99
- />,
100
- );
101
- await waitUntilExit();
102
- tuiActive = false;
103
-
104
- // On exit, print a hash that resumes this conversation later (à la Claude
105
- // Code). The latest persisted session carries the id used this run; it only
106
- // exists once at least one turn has been saved.
107
- const last = loadLatest(process.cwd());
108
- if (last && last.messages.length > 0) {
109
- process.stdout.write(`\nResume this session: ${NAME} --resume ${last.id}\n`);
110
- }
111
- } catch (err) {
112
- // Configuration/resolution errors are expected and user-facing — print them
113
- // cleanly without a stack trace.
114
- process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}\n`);
115
- process.exitCode = 1;
116
- }
117
- });
118
-
119
- await program.parseAsync(process.argv);
120
- }
121
-
122
- // Headless one-shot: stream the answer to stdout, surfacing tool activity on stderr.
123
- async function runPrint(modelSpec: string, prompt: string, confineToCwd: boolean) {
124
- if (!prompt) {
125
- process.stderr.write("No prompt provided.\n");
126
- process.exitCode = 1;
127
- return;
128
- }
129
- const session = createSession({ config: loadConfig(), modelSpec, cwd: process.cwd(), confineToCwd });
130
- for await (const ev of session.engine.send(prompt)) {
131
- switch (ev.type) {
132
- case "text":
133
- process.stdout.write(ev.text);
134
- break;
135
- case "tool-call":
136
- process.stderr.write(`\n· ${ev.name} ${JSON.stringify(ev.input)}\n`);
137
- break;
138
- case "tool-error":
139
- process.stderr.write(`\n! ${ev.name}: ${ev.error}\n`);
140
- break;
141
- case "routed":
142
- process.stderr.write(
143
- ev.missing && ev.missing.length > 0
144
- ? `\n⚠ no model configured for ${ev.missing.join("/")} input\n`
145
- : `\n↪ ${ev.label}${ev.reason ? ` · ${ev.reason}` : ""}\n`,
146
- );
147
- break;
148
- case "error":
149
- process.stderr.write(`\nError: ${ev.error}${ev.hint ? `\n${ev.hint}` : ""}\n`);
150
- process.exitCode = 1;
151
- break;
152
- case "finish":
153
- process.stdout.write(
154
- `\n\n[${modelSpec} · ${ev.usage.totalTokens} tokens · ${ev.finishReason}]\n`,
155
- );
156
- break;
157
- }
158
- }
159
- }
160
-
161
- main().catch((err) => {
162
- // Redact + summarize rather than dumping the raw error object, which may carry
163
- // request bodies or key material in provider-error fields.
164
- const d = describeError(err);
165
- process.stderr.write(`Error: ${d.message}${d.hint ? `\n${d.hint}` : ""}\n`);
166
- process.exit(1);
167
- });
package/src/mcp/client.ts DELETED
@@ -1,236 +0,0 @@
1
- import { existsSync, readFileSync } from "node:fs";
2
- import { tool, jsonSchema, type ToolSet } from "ai";
3
- import { Client } from "@modelcontextprotocol/sdk/client/index.js";
4
- import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
5
- import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
6
- import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
7
- import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";
8
- import { globalPaths, projectPaths } from "../config/paths.ts";
9
- import { type PermissionGate, PermissionDeniedError } from "../permissions/gate.ts";
10
- import { FileOAuthProvider, type AuthorizePrompt } from "./oauth.ts";
11
-
12
- // A local stdio server (launched as a child process) or a remote HTTP server.
13
- export type StdioServerConfig = { command: string; args?: string[]; env?: Record<string, string> };
14
- export type HttpServerConfig = { url: string; headers?: Record<string, string>; transport?: "http" | "sse" };
15
- export type McpServerConfig = StdioServerConfig | HttpServerConfig;
16
- export type McpServers = Record<string, McpServerConfig>;
17
-
18
- function isStdio(cfg: McpServerConfig): cfg is StdioServerConfig {
19
- return typeof (cfg as StdioServerConfig).command === "string";
20
- }
21
-
22
- export interface McpToolDef {
23
- name: string;
24
- description?: string;
25
- inputSchema?: Record<string, unknown>;
26
- }
27
-
28
- // Read mcp.json from project then user scope (project overrides). Accepts either a
29
- // top-level map or a { "mcpServers": {...} } wrapper. An entry is kept if it names a
30
- // stdio `command` or a remote `url`; anything else is dropped.
31
- export function loadMcpServers(cwd: string = process.cwd()): McpServers {
32
- const merge = (path: string, into: McpServers) => {
33
- if (!existsSync(path)) return;
34
- try {
35
- const raw = JSON.parse(readFileSync(path, "utf8"));
36
- const map = (raw && typeof raw === "object" && raw.mcpServers) || raw;
37
- if (map && typeof map === "object") {
38
- for (const [name, cfg] of Object.entries(map as Record<string, unknown>)) {
39
- if (cfg && typeof cfg === "object") {
40
- const c = cfg as Partial<StdioServerConfig & HttpServerConfig>;
41
- if (typeof c.command === "string" || typeof c.url === "string") {
42
- into[name] = cfg as McpServerConfig;
43
- }
44
- }
45
- }
46
- }
47
- } catch {
48
- /* malformed mcp.json → skip */
49
- }
50
- };
51
- const servers: McpServers = {};
52
- merge(globalPaths().mcp, servers);
53
- merge(projectPaths(cwd).mcp, servers);
54
- return servers;
55
- }
56
-
57
- // process.env as a clean string map, so stdio servers inherit our environment
58
- // (StdioClientTransport otherwise launches with a minimal default env).
59
- function inheritedEnv(extra?: Record<string, string>): Record<string, string> {
60
- const base: Record<string, string> = {};
61
- for (const [k, v] of Object.entries(process.env)) if (v !== undefined) base[k] = v;
62
- return { ...base, ...extra };
63
- }
64
-
65
- function withTimeout<T>(p: Promise<T>, ms: number, label: string): Promise<T> {
66
- return new Promise<T>((resolve, reject) => {
67
- const timer = setTimeout(() => reject(new Error(`${label} timed out`)), ms);
68
- p.then((v) => (clearTimeout(timer), resolve(v)), (e) => (clearTimeout(timer), reject(e)));
69
- });
70
- }
71
-
72
- // An MCP client over the official SDK, transport chosen from config: a local child
73
- // process (stdio) or a remote server (Streamable HTTP, falling back to legacy SSE).
74
- // Exposes only what the agent needs: connect, tools/list, tools/call, close.
75
- export class McpClient {
76
- private client?: Client;
77
-
78
- constructor(
79
- private readonly name: string,
80
- private readonly cfg: McpServerConfig,
81
- private readonly cwd: string,
82
- private readonly onAuthorize?: AuthorizePrompt,
83
- ) {}
84
-
85
- async connect(timeoutMs = 10_000): Promise<void> {
86
- const client = new Client({ name: "privateer", version: "0.1.0" }, { capabilities: {} });
87
- if (isStdio(this.cfg)) {
88
- const transport = new StdioClientTransport({
89
- command: this.cfg.command,
90
- args: this.cfg.args ?? [],
91
- env: inheritedEnv(this.cfg.env),
92
- cwd: this.cwd,
93
- stderr: "ignore", // keep server logs out of the TUI
94
- });
95
- await withTimeout(client.connect(transport), timeoutMs, `MCP server "${this.name}" connect`);
96
- this.client = client;
97
- return;
98
- }
99
-
100
- const url = new URL(this.cfg.url);
101
- const requestInit = this.cfg.headers ? { headers: this.cfg.headers } : undefined;
102
- // Static header auth wins. Otherwise attach an interactive OAuth provider — it
103
- // stays dormant unless the server actually answers 401.
104
- const authProvider = this.cfg.headers
105
- ? undefined
106
- : new FileOAuthProvider(this.name, this.cfg.url, this.onAuthorize);
107
- const sse = () => new SSEClientTransport(url, { requestInit, authProvider });
108
-
109
- if (this.cfg.transport === "sse") {
110
- await this.tryConnect(client, sse, authProvider, timeoutMs);
111
- } else {
112
- const http = () => new StreamableHTTPClientTransport(url, { requestInit, authProvider });
113
- try {
114
- await this.tryConnect(client, http, authProvider, timeoutMs);
115
- } catch (err) {
116
- // A server that only speaks the legacy HTTP+SSE transport rejects the
117
- // Streamable-HTTP handshake; retry once over SSE before giving up.
118
- if (this.cfg.transport !== "http") await this.tryConnect(client, sse, authProvider, timeoutMs);
119
- else throw err;
120
- }
121
- }
122
- this.client = client;
123
- }
124
-
125
- // Connect with one transport kind. On a 401 with an OAuth provider configured,
126
- // run the interactive consent dance (browser → loopback redirect → code →
127
- // token exchange) and reconnect with a fresh transport that picks up the tokens.
128
- private async tryConnect(
129
- client: Client,
130
- make: () => StreamableHTTPClientTransport | SSEClientTransport,
131
- provider: FileOAuthProvider | undefined,
132
- timeoutMs: number,
133
- ): Promise<void> {
134
- const transport = make();
135
- try {
136
- await withTimeout(client.connect(transport), timeoutMs, `MCP server "${this.name}" connect`);
137
- } catch (err) {
138
- if (err instanceof UnauthorizedError && provider) {
139
- const code = await provider.waitForCode();
140
- await transport.finishAuth(code);
141
- await withTimeout(client.connect(make()), timeoutMs, `MCP server "${this.name}" reconnect`);
142
- } else {
143
- throw err;
144
- }
145
- }
146
- }
147
-
148
- async listTools(): Promise<McpToolDef[]> {
149
- const res = await this.client!.listTools();
150
- return Array.isArray(res?.tools)
151
- ? res.tools.map((t) => ({ name: t.name, description: t.description, inputSchema: t.inputSchema as Record<string, unknown> }))
152
- : [];
153
- }
154
-
155
- async callTool(name: string, args: unknown): Promise<string> {
156
- const res = await this.client!.callTool({ name, arguments: (args ?? {}) as Record<string, unknown> });
157
- return formatContent(res);
158
- }
159
-
160
- close(): void {
161
- void this.client?.close();
162
- }
163
- }
164
-
165
- function formatContent(result: any): string {
166
- const content = result?.content;
167
- if (Array.isArray(content)) {
168
- const text = content
169
- .map((c: any) => (c?.type === "text" ? c.text : `[${c?.type ?? "content"}]`))
170
- .join("\n");
171
- return result?.isError ? `Error: ${text}` : text || "(no output)";
172
- }
173
- return JSON.stringify(result ?? {});
174
- }
175
-
176
- // Adapt one server's MCP tools into AI-SDK tools, namespaced as "<server>__<tool>" and
177
- // routed through the permission gate (MCP calls are external, so they prompt by default).
178
- export function adaptMcpTools(
179
- server: string,
180
- client: McpClient,
181
- defs: McpToolDef[],
182
- gate: PermissionGate,
183
- ): ToolSet {
184
- const set: ToolSet = {};
185
- for (const d of defs) {
186
- const name = `${server}__${d.name}`;
187
- set[name] = tool({
188
- description: d.description ?? `${d.name} (MCP server: ${server})`,
189
- inputSchema: jsonSchema((d.inputSchema as any) ?? { type: "object", properties: {} }),
190
- execute: async (args: unknown) => {
191
- const decision = await gate.request({
192
- tool: name,
193
- kind: "fetch",
194
- title: `MCP ${server}: ${d.name}`,
195
- detail: JSON.stringify(args ?? {}).slice(0, 120),
196
- });
197
- if (decision === "deny") throw new PermissionDeniedError(name);
198
- return client.callTool(d.name, args);
199
- },
200
- });
201
- }
202
- return set;
203
- }
204
-
205
- export interface McpConnection {
206
- tools: ToolSet;
207
- clients: McpClient[];
208
- status: { server: string; tools: number; error?: string }[];
209
- }
210
-
211
- // Connect every configured server, returning the merged toolset, the live clients (to
212
- // close on teardown), and a per-server status. Failures are isolated per server.
213
- export async function connectMcpServers(
214
- servers: McpServers,
215
- cwd: string,
216
- gate: PermissionGate,
217
- onAuthorize?: AuthorizePrompt,
218
- ): Promise<McpConnection> {
219
- const tools: ToolSet = {};
220
- const clients: McpClient[] = [];
221
- const status: McpConnection["status"] = [];
222
- for (const [name, cfg] of Object.entries(servers)) {
223
- const client = new McpClient(name, cfg, cwd, onAuthorize);
224
- try {
225
- await client.connect();
226
- const defs = await client.listTools();
227
- Object.assign(tools, adaptMcpTools(name, client, defs, gate));
228
- clients.push(client);
229
- status.push({ server: name, tools: defs.length });
230
- } catch (err) {
231
- client.close();
232
- status.push({ server: name, tools: 0, error: err instanceof Error ? err.message : String(err) });
233
- }
234
- }
235
- return { tools, clients, status };
236
- }