privateer-agent 0.2.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 (111) hide show
  1. package/README.md +135 -443
  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 +57 -0
  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 -46
  11. package/src/config/permissionMode.ts +5 -0
  12. package/src/crypto/outboxSeal.ts +61 -0
  13. package/src/daemon/index.ts +204 -121
  14. package/src/daemon/ipc.ts +1 -1
  15. package/src/engine/errors.ts +85 -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 +11 -14
  21. package/src/permissions/mode.ts +6 -2
  22. package/src/permissions/{uiGate.ts → modeGate.ts} +18 -3
  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 +15 -0
  27. package/src/remote/remoteBridge.ts +152 -0
  28. package/src/routines/delivery.ts +118 -9
  29. package/src/routines/schema.ts +24 -5
  30. package/src/routines/store.ts +44 -1
  31. package/src/routines/toolSelect.ts +3 -1
  32. package/src/session.ts +81 -273
  33. package/src/tools/routine.ts +120 -101
  34. package/src/tools/saveAttachment.ts +39 -42
  35. package/src/tools/sendFile.ts +75 -0
  36. package/src/util/attachmentStore.ts +18 -35
  37. package/src/util/redact.ts +33 -3
  38. package/LICENSE +0 -21
  39. package/src/agents/loader.ts +0 -49
  40. package/src/commands/custom.ts +0 -123
  41. package/src/commands/registry.ts +0 -618
  42. package/src/components/AgentGroupView.tsx +0 -104
  43. package/src/components/App.tsx +0 -1626
  44. package/src/components/ApprovalPrompt.tsx +0 -49
  45. package/src/components/Banner.tsx +0 -78
  46. package/src/components/Markdown.tsx +0 -183
  47. package/src/components/ModeHint.tsx +0 -40
  48. package/src/components/ModelPicker.tsx +0 -302
  49. package/src/components/Onboarding.tsx +0 -203
  50. package/src/components/OptionPicker.tsx +0 -134
  51. package/src/components/PlanConfirm.tsx +0 -37
  52. package/src/components/PrivateerLogin.tsx +0 -109
  53. package/src/components/PromptInput.tsx +0 -602
  54. package/src/components/RewindPicker.tsx +0 -69
  55. package/src/components/Root.tsx +0 -116
  56. package/src/components/SessionPicker.tsx +0 -64
  57. package/src/components/StatusBar.tsx +0 -131
  58. package/src/components/TodoPanel.tsx +0 -36
  59. package/src/components/ToolCallView.tsx +0 -113
  60. package/src/components/Transcript.tsx +0 -210
  61. package/src/components/figures.ts +0 -14
  62. package/src/components/promptModel.ts +0 -73
  63. package/src/components/spinnerVerbs.ts +0 -46
  64. package/src/components/theme.ts +0 -57
  65. package/src/components/types.ts +0 -34
  66. package/src/components/useTeeShield.ts +0 -104
  67. package/src/components/useTerminalWidth.ts +0 -24
  68. package/src/components/useZdrShield.ts +0 -126
  69. package/src/config/load.ts +0 -115
  70. package/src/config/schema.ts +0 -94
  71. package/src/context/outputStyles.ts +0 -42
  72. package/src/context/projectInfo.ts +0 -59
  73. package/src/context/systemPrompt.ts +0 -176
  74. package/src/engine/QueryEngine.ts +0 -399
  75. package/src/hooks/engine.ts +0 -155
  76. package/src/main.tsx +0 -209
  77. package/src/mcp/client.ts +0 -251
  78. package/src/mcp/oauth.ts +0 -245
  79. package/src/memory/auto.ts +0 -146
  80. package/src/memory/checkpoints.ts +0 -227
  81. package/src/memory/store.ts +0 -127
  82. package/src/providers/attestation.ts +0 -149
  83. package/src/providers/capabilities.ts +0 -104
  84. package/src/providers/models.ts +0 -183
  85. package/src/providers/registry.ts +0 -71
  86. package/src/providers/resolve.ts +0 -78
  87. package/src/skills/installer.ts +0 -222
  88. package/src/skills/loader.ts +0 -88
  89. package/src/tools/askUser.ts +0 -92
  90. package/src/tools/bash.ts +0 -98
  91. package/src/tools/context.ts +0 -128
  92. package/src/tools/edit.ts +0 -67
  93. package/src/tools/exec.ts +0 -60
  94. package/src/tools/glob.ts +0 -39
  95. package/src/tools/grep.ts +0 -86
  96. package/src/tools/index.ts +0 -83
  97. package/src/tools/memory.ts +0 -53
  98. package/src/tools/processRegistry.ts +0 -77
  99. package/src/tools/read.ts +0 -42
  100. package/src/tools/sendFileToClient.ts +0 -55
  101. package/src/tools/skill.ts +0 -44
  102. package/src/tools/task.ts +0 -52
  103. package/src/tools/todo.ts +0 -36
  104. package/src/tools/todoStore.ts +0 -31
  105. package/src/tools/walk.ts +0 -44
  106. package/src/tools/web.ts +0 -145
  107. package/src/tools/worktree.ts +0 -145
  108. package/src/tools/write.ts +0 -40
  109. package/src/util/images.ts +0 -378
  110. package/src/util/limit.ts +0 -32
  111. 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,209 +0,0 @@
1
- import React from "react";
2
- import { render } from "ink";
3
- import { Command } from "commander";
4
- import { spawn } from "node:child_process";
5
- import { openSync } from "node:fs";
6
- import { join } from "node:path";
7
- import { Root } from "./components/Root.tsx";
8
- import { NAME, VERSION, DESCRIPTION } from "./version.ts";
9
- import { loadConfig, globalDir } from "./config/load.ts";
10
- import { createSession } from "./session.ts";
11
- import { loadLatest, loadSession } from "./memory/store.ts";
12
- import { configuredProviders } from "./providers/resolve.ts";
13
- import { describeError } from "./engine/errors.ts";
14
- import { runDaemon } from "./daemon/index.ts";
15
- import { revokeChildSession } from "./auth/privateer.ts";
16
-
17
- // Set while Ink owns the screen. A stray unhandled rejection while the TUI is up
18
- // must NOT reach stdout/stderr — Node's default printer dumps the whole error
19
- // object (the request body included) unredacted and scrambles the render. The
20
- // underlying error has already surfaced cleanly via the engine's `error` event,
21
- // so here we just swallow the duplicate.
22
- let tuiActive = false;
23
-
24
- process.on("unhandledRejection", (reason) => {
25
- if (tuiActive) return;
26
- const d = describeError(reason);
27
- process.stderr.write(`\nError: ${d.message}${d.hint ? `\n${d.hint}` : ""}\n`);
28
- process.exitCode = 1;
29
- });
30
-
31
- interface CliOptions {
32
- print?: boolean;
33
- model?: string;
34
- cwd?: string;
35
- dangerouslySkipPermissions?: boolean;
36
- // Commander treats `--no-quarter` as a negatable boolean: `quarter` is true by
37
- // default and becomes false when the flag is passed.
38
- quarter?: boolean;
39
- continue?: boolean;
40
- resume?: string;
41
- onboard?: boolean;
42
- // Commander negatable boolean: `confine` is true by default, false with --no-confine.
43
- confine?: boolean;
44
- }
45
-
46
- const DEFAULT_MODEL = "anthropic:claude-opus-4-8";
47
-
48
- async function main() {
49
- const program = new Command();
50
-
51
- program
52
- .name(NAME)
53
- .description(DESCRIPTION)
54
- .version(VERSION, "-v, --version")
55
- .argument("[prompt...]", "prompt to send (with -p, runs headless)")
56
- .option("-p, --print", "print mode: run headless and write the answer to stdout")
57
- .option("-m, --model <provider:model>", "model to use, e.g. openrouter:anthropic/claude-opus-4.8")
58
- .option("-C, --cwd <dir>", "working directory")
59
- .option("--no-confine", "let the agent read/edit outside the working directory without prompting")
60
- .option("--dangerously-skip-permissions", "auto-approve all tool actions (bypass mode)")
61
- .option("--no-quarter", "auto-approve all tool actions, taking no prisoners (bypass mode)")
62
- .option("-c, --continue", "resume the most recent session in this directory")
63
- .option("-r, --resume <id>", "resume a specific session by id (printed on exit)")
64
- .option("--onboard", "run the provider/key setup flow")
65
- .action(async (promptParts: string[], options: CliOptions) => {
66
- // Terminal-window close (SIGHUP) or a kill (SIGTERM) bypasses the normal
67
- // exit path below — revoke this terminal's Privateer session first so it
68
- // drops off the app's Linked Devices immediately instead of lingering
69
- // until server-side expiry. Installing a handler replaces Node's default
70
- // terminate-on-signal, so exit explicitly with the conventional code.
71
- const revokeAndExit = (code: number) => () => {
72
- void revokeChildSession().finally(() => process.exit(code));
73
- };
74
- process.on("SIGHUP", revokeAndExit(129));
75
- process.on("SIGTERM", revokeAndExit(143));
76
- try {
77
- if (options.cwd) process.chdir(options.cwd);
78
- const config = loadConfig();
79
- if (options.dangerouslySkipPermissions || options.quarter === false)
80
- config.permissionMode = "bypass";
81
- // --no-confine turns off the working-directory boundary for this run.
82
- if (options.confine === false) config.confineToCwd = false;
83
- // --resume <id> loads a specific session (the id printed on a prior exit);
84
- // --continue loads the most recent one.
85
- const resume = options.resume
86
- ? loadSession(process.cwd(), options.resume)
87
- : options.continue
88
- ? loadLatest(process.cwd())
89
- : null;
90
- if (options.resume && !resume) {
91
- process.stderr.write(`No session "${options.resume}" found in this directory.\n`);
92
- }
93
- const modelSpec = options.model ?? resume?.modelSpec ?? config.defaultModel ?? DEFAULT_MODEL;
94
-
95
- if (options.print) {
96
- await runPrint(modelSpec, promptParts.join(" ").trim(), config.confineToCwd);
97
- await revokeChildSession();
98
- return;
99
- }
100
-
101
- // First-run onboarding: no provider has credentials yet (and we're not
102
- // resuming). Also forceable with --onboard.
103
- const noProviderReady = !configuredProviders(config).some((p) => p.ready);
104
- const startInOnboarding = Boolean(options.onboard) || (!resume && noProviderReady);
105
-
106
- // Interactive TUI.
107
- tuiActive = true;
108
- const { waitUntilExit } = render(
109
- <Root
110
- config={config}
111
- modelSpec={modelSpec}
112
- cwd={process.cwd()}
113
- resume={resume}
114
- startInOnboarding={startInOnboarding}
115
- />,
116
- );
117
- await waitUntilExit();
118
- tuiActive = false;
119
-
120
- // This terminal is done — release its Privateer session so it leaves the
121
- // app's Linked Devices right away. Started before the resume hint prints
122
- // and awaited after, so it doesn't delay the output.
123
- const revoked = revokeChildSession();
124
-
125
- // On exit, print a hash that resumes this conversation later (à la Claude
126
- // Code). The latest persisted session carries the id used this run; it only
127
- // exists once at least one turn has been saved.
128
- const last = loadLatest(process.cwd());
129
- if (last && last.messages.length > 0) {
130
- process.stdout.write(`\nResume this session: ${NAME} --resume ${last.id}\n`);
131
- }
132
- await revoked;
133
- } catch (err) {
134
- // Configuration/resolution errors are expected and user-facing — print them
135
- // cleanly without a stack trace.
136
- process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}\n`);
137
- process.exitCode = 1;
138
- }
139
- });
140
-
141
- // The scheduler daemon: a resident process that fires routines on their cron
142
- // schedule. Runs in the foreground by default; --detach forks a background copy.
143
- program
144
- .command("daemon")
145
- .description("run the scheduler that fires saved routines on their cron schedule")
146
- .option("--detach", "start the daemon in the background and return")
147
- .action((opts: { detach?: boolean }) => {
148
- if (opts.detach) {
149
- const logPath = join(globalDir(), "daemon.log");
150
- const out = openSync(logPath, "a");
151
- // Re-invoke this same runtime + script without --detach, fully detached.
152
- const args = process.argv.slice(1).filter((a) => a !== "--detach");
153
- const child = spawn(process.argv[0], args, { detached: true, stdio: ["ignore", out, out] });
154
- child.unref();
155
- process.stdout.write(`Daemon started in background (pid ${child.pid}).\nLogs: ${logPath}\n`);
156
- return;
157
- }
158
- runDaemon();
159
- });
160
-
161
- await program.parseAsync(process.argv);
162
- }
163
-
164
- // Headless one-shot: stream the answer to stdout, surfacing tool activity on stderr.
165
- async function runPrint(modelSpec: string, prompt: string, confineToCwd: boolean) {
166
- if (!prompt) {
167
- process.stderr.write("No prompt provided.\n");
168
- process.exitCode = 1;
169
- return;
170
- }
171
- const session = createSession({ config: loadConfig(), modelSpec, cwd: process.cwd(), confineToCwd });
172
- for await (const ev of session.engine.send(prompt)) {
173
- switch (ev.type) {
174
- case "text":
175
- process.stdout.write(ev.text);
176
- break;
177
- case "tool-call":
178
- process.stderr.write(`\n· ${ev.name} ${JSON.stringify(ev.input)}\n`);
179
- break;
180
- case "tool-error":
181
- process.stderr.write(`\n! ${ev.name}: ${ev.error}\n`);
182
- break;
183
- case "routed":
184
- process.stderr.write(
185
- ev.missing && ev.missing.length > 0
186
- ? `\n⚠ no model configured for ${ev.missing.join("/")} input\n`
187
- : `\n↪ ${ev.label}${ev.reason ? ` · ${ev.reason}` : ""}\n`,
188
- );
189
- break;
190
- case "error":
191
- process.stderr.write(`\nError: ${ev.error}${ev.hint ? `\n${ev.hint}` : ""}\n`);
192
- process.exitCode = 1;
193
- break;
194
- case "finish":
195
- process.stdout.write(
196
- `\n\n[${modelSpec} · ${ev.usage.totalTokens} tokens · ${ev.finishReason}]\n`,
197
- );
198
- break;
199
- }
200
- }
201
- }
202
-
203
- main().catch((err) => {
204
- // Redact + summarize rather than dumping the raw error object, which may carry
205
- // request bodies or key material in provider-error fields.
206
- const d = describeError(err);
207
- process.stderr.write(`Error: ${d.message}${d.hint ? `\n${d.hint}` : ""}\n`);
208
- process.exit(1);
209
- });
package/src/mcp/client.ts DELETED
@@ -1,251 +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
- // Standard MCP behavioral hints. We use `destructiveHint`/`readOnlyHint` to
27
- // decide whether a tool may auto-approve: a tool that performs an irreversible
28
- // external action marks itself destructive so it ALWAYS reaches the human.
29
- annotations?: { readOnlyHint?: boolean; destructiveHint?: boolean };
30
- }
31
-
32
- // Read mcp.json from project then user scope (project overrides). Accepts either a
33
- // top-level map or a { "mcpServers": {...} } wrapper. An entry is kept if it names a
34
- // stdio `command` or a remote `url`; anything else is dropped.
35
- export function loadMcpServers(cwd: string = process.cwd()): McpServers {
36
- const merge = (path: string, into: McpServers) => {
37
- if (!existsSync(path)) return;
38
- try {
39
- const raw = JSON.parse(readFileSync(path, "utf8"));
40
- const map = (raw && typeof raw === "object" && raw.mcpServers) || raw;
41
- if (map && typeof map === "object") {
42
- for (const [name, cfg] of Object.entries(map as Record<string, unknown>)) {
43
- if (cfg && typeof cfg === "object") {
44
- const c = cfg as Partial<StdioServerConfig & HttpServerConfig>;
45
- if (typeof c.command === "string" || typeof c.url === "string") {
46
- into[name] = cfg as McpServerConfig;
47
- }
48
- }
49
- }
50
- }
51
- } catch {
52
- /* malformed mcp.json → skip */
53
- }
54
- };
55
- const servers: McpServers = {};
56
- merge(globalPaths().mcp, servers);
57
- merge(projectPaths(cwd).mcp, servers);
58
- return servers;
59
- }
60
-
61
- // process.env as a clean string map, so stdio servers inherit our environment
62
- // (StdioClientTransport otherwise launches with a minimal default env).
63
- function inheritedEnv(extra?: Record<string, string>): Record<string, string> {
64
- const base: Record<string, string> = {};
65
- for (const [k, v] of Object.entries(process.env)) if (v !== undefined) base[k] = v;
66
- return { ...base, ...extra };
67
- }
68
-
69
- function withTimeout<T>(p: Promise<T>, ms: number, label: string): Promise<T> {
70
- return new Promise<T>((resolve, reject) => {
71
- const timer = setTimeout(() => reject(new Error(`${label} timed out`)), ms);
72
- p.then((v) => (clearTimeout(timer), resolve(v)), (e) => (clearTimeout(timer), reject(e)));
73
- });
74
- }
75
-
76
- // An MCP client over the official SDK, transport chosen from config: a local child
77
- // process (stdio) or a remote server (Streamable HTTP, falling back to legacy SSE).
78
- // Exposes only what the agent needs: connect, tools/list, tools/call, close.
79
- export class McpClient {
80
- private client?: Client;
81
-
82
- constructor(
83
- private readonly name: string,
84
- private readonly cfg: McpServerConfig,
85
- private readonly cwd: string,
86
- private readonly onAuthorize?: AuthorizePrompt,
87
- ) {}
88
-
89
- async connect(timeoutMs = 10_000): Promise<void> {
90
- const client = new Client({ name: "privateer", version: "0.1.0" }, { capabilities: {} });
91
- if (isStdio(this.cfg)) {
92
- const transport = new StdioClientTransport({
93
- command: this.cfg.command,
94
- args: this.cfg.args ?? [],
95
- env: inheritedEnv(this.cfg.env),
96
- cwd: this.cwd,
97
- stderr: "ignore", // keep server logs out of the TUI
98
- });
99
- await withTimeout(client.connect(transport), timeoutMs, `MCP server "${this.name}" connect`);
100
- this.client = client;
101
- return;
102
- }
103
-
104
- const url = new URL(this.cfg.url);
105
- const requestInit = this.cfg.headers ? { headers: this.cfg.headers } : undefined;
106
- // Static header auth wins. Otherwise attach an interactive OAuth provider — it
107
- // stays dormant unless the server actually answers 401.
108
- const authProvider = this.cfg.headers
109
- ? undefined
110
- : new FileOAuthProvider(this.name, this.cfg.url, this.onAuthorize);
111
- const sse = () => new SSEClientTransport(url, { requestInit, authProvider });
112
-
113
- if (this.cfg.transport === "sse") {
114
- await this.tryConnect(client, sse, authProvider, timeoutMs);
115
- } else {
116
- const http = () => new StreamableHTTPClientTransport(url, { requestInit, authProvider });
117
- try {
118
- await this.tryConnect(client, http, authProvider, timeoutMs);
119
- } catch (err) {
120
- // A server that only speaks the legacy HTTP+SSE transport rejects the
121
- // Streamable-HTTP handshake; retry once over SSE before giving up.
122
- if (this.cfg.transport !== "http") await this.tryConnect(client, sse, authProvider, timeoutMs);
123
- else throw err;
124
- }
125
- }
126
- this.client = client;
127
- }
128
-
129
- // Connect with one transport kind. On a 401 with an OAuth provider configured,
130
- // run the interactive consent dance (browser → loopback redirect → code →
131
- // token exchange) and reconnect with a fresh transport that picks up the tokens.
132
- private async tryConnect(
133
- client: Client,
134
- make: () => StreamableHTTPClientTransport | SSEClientTransport,
135
- provider: FileOAuthProvider | undefined,
136
- timeoutMs: number,
137
- ): Promise<void> {
138
- const transport = make();
139
- try {
140
- await withTimeout(client.connect(transport), timeoutMs, `MCP server "${this.name}" connect`);
141
- } catch (err) {
142
- if (err instanceof UnauthorizedError && provider) {
143
- const code = await provider.waitForCode();
144
- await transport.finishAuth(code);
145
- await withTimeout(client.connect(make()), timeoutMs, `MCP server "${this.name}" reconnect`);
146
- } else {
147
- throw err;
148
- }
149
- }
150
- }
151
-
152
- async listTools(): Promise<McpToolDef[]> {
153
- const res = await this.client!.listTools();
154
- return Array.isArray(res?.tools)
155
- ? res.tools.map((t) => ({
156
- name: t.name,
157
- description: t.description,
158
- inputSchema: t.inputSchema as Record<string, unknown>,
159
- annotations: t.annotations as McpToolDef["annotations"],
160
- }))
161
- : [];
162
- }
163
-
164
- async callTool(name: string, args: unknown): Promise<string> {
165
- const res = await this.client!.callTool({ name, arguments: (args ?? {}) as Record<string, unknown> });
166
- return formatContent(res);
167
- }
168
-
169
- close(): void {
170
- void this.client?.close();
171
- }
172
- }
173
-
174
- function formatContent(result: any): string {
175
- const content = result?.content;
176
- if (Array.isArray(content)) {
177
- const text = content
178
- .map((c: any) => (c?.type === "text" ? c.text : `[${c?.type ?? "content"}]`))
179
- .join("\n");
180
- return result?.isError ? `Error: ${text}` : text || "(no output)";
181
- }
182
- return JSON.stringify(result ?? {});
183
- }
184
-
185
- // Adapt one server's MCP tools into AI-SDK tools, namespaced as "<server>__<tool>" and
186
- // routed through the permission gate (MCP calls are external, so they prompt by default).
187
- export function adaptMcpTools(
188
- server: string,
189
- client: McpClient,
190
- defs: McpToolDef[],
191
- gate: PermissionGate,
192
- ): ToolSet {
193
- const set: ToolSet = {};
194
- for (const d of defs) {
195
- const name = `${server}__${d.name}`;
196
- // A mutating tool (e.g. send email, delete file) marks itself destructive. We
197
- // map that to `alwaysAsk`, which the gate never auto-approves — so it always
198
- // prompts the human (phone on remote turns, terminal otherwise) even under
199
- // bypass mode or the allowlist. Read-only tools follow the normal policy.
200
- const destructive = d.annotations?.destructiveHint === true && d.annotations?.readOnlyHint !== true;
201
- set[name] = tool({
202
- description: d.description ?? `${d.name} (MCP server: ${server})`,
203
- inputSchema: jsonSchema((d.inputSchema as any) ?? { type: "object", properties: {} }),
204
- execute: async (args: unknown) => {
205
- const decision = await gate.request({
206
- tool: name,
207
- kind: "fetch",
208
- title: `MCP ${server}: ${d.name}`,
209
- detail: JSON.stringify(args ?? {}).slice(0, 120),
210
- alwaysAsk: destructive,
211
- });
212
- if (decision === "deny") throw new PermissionDeniedError(name);
213
- return client.callTool(d.name, args);
214
- },
215
- });
216
- }
217
- return set;
218
- }
219
-
220
- export interface McpConnection {
221
- tools: ToolSet;
222
- clients: McpClient[];
223
- status: { server: string; tools: number; error?: string }[];
224
- }
225
-
226
- // Connect every configured server, returning the merged toolset, the live clients (to
227
- // close on teardown), and a per-server status. Failures are isolated per server.
228
- export async function connectMcpServers(
229
- servers: McpServers,
230
- cwd: string,
231
- gate: PermissionGate,
232
- onAuthorize?: AuthorizePrompt,
233
- ): Promise<McpConnection> {
234
- const tools: ToolSet = {};
235
- const clients: McpClient[] = [];
236
- const status: McpConnection["status"] = [];
237
- for (const [name, cfg] of Object.entries(servers)) {
238
- const client = new McpClient(name, cfg, cwd, onAuthorize);
239
- try {
240
- await client.connect();
241
- const defs = await client.listTools();
242
- Object.assign(tools, adaptMcpTools(name, client, defs, gate));
243
- clients.push(client);
244
- status.push({ server: name, tools: defs.length });
245
- } catch (err) {
246
- client.close();
247
- status.push({ server: name, tools: 0, error: err instanceof Error ? err.message : String(err) });
248
- }
249
- }
250
- return { tools, clients, status };
251
- }