@matterailab/orbcode 0.2.2 → 0.2.4

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.
@@ -18,6 +18,8 @@ const SETTINGS_KEYS = [
18
18
  "baseUrl",
19
19
  "apiKey",
20
20
  "env",
21
+ "enabledMcpServers",
22
+ "disabledMcpServers",
21
23
  ];
22
24
  export function getConfigDir() {
23
25
  return process.env.MATTERAI_CONFIG_DIR || path.join(os.homedir(), ".orbcode");
@@ -150,6 +152,12 @@ export function loadSettings() {
150
152
  if (Array.isArray(fileSettings.customModels)) {
151
153
  customModels.push(...fileSettings.customModels);
152
154
  }
155
+ // MCP servers: merge across scopes (local overrides project overrides
156
+ // user on name collisions). The MCP config layer also reads .mcp.json;
157
+ // settings.json mcpServers are the per-user/per-machine override path.
158
+ if (fileSettings.mcpServers && typeof fileSettings.mcpServers === "object") {
159
+ settings.mcpServers = { ...(settings.mcpServers ?? {}), ...fileSettings.mcpServers };
160
+ }
153
161
  }
154
162
  if (customModels.length > 0) {
155
163
  settings.customModels = customModels;
@@ -210,3 +218,22 @@ export function saveSettings(settings) {
210
218
  };
211
219
  fs.writeFileSync(getConfigPath(), JSON.stringify(toPersist, null, "\t") + "\n", { mode: 0o600 });
212
220
  }
221
+ /**
222
+ * Persist the enabled/disabled MCP server lists to the local project
223
+ * settings.json (`.orbcode/settings.json`). These are per-project approval
224
+ * decisions, so they live in the local scope, not in config.json.
225
+ */
226
+ export function saveMcpApproval(cwd, enabled, disabled) {
227
+ const settingsPath = path.join(cwd, ".orbcode", "settings.json");
228
+ let existing = {};
229
+ try {
230
+ existing = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
231
+ }
232
+ catch {
233
+ // file may not exist yet
234
+ }
235
+ existing.enabledMcpServers = [...new Set(enabled)];
236
+ existing.disabledMcpServers = [...new Set(disabled)];
237
+ fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
238
+ fs.writeFileSync(settingsPath, JSON.stringify(existing, null, "\t") + "\n", { mode: 0o600 });
239
+ }
@@ -9,6 +9,8 @@ import { walkFiles } from "../tools/executors/listFiles.js";
9
9
  import { previewFileChange } from "../tools/executors/files.js";
10
10
  import { getSessionFilePath, saveSession } from "./sessions.js";
11
11
  import { HookRunner } from "./hooks.js";
12
+ import { loadMemoryFiles } from "../memory/loader.js";
13
+ import { loadSkills } from "../skills/loader.js";
12
14
  const MAX_STEPS_PER_TURN = 50;
13
15
  const RESULT_PREVIEW_LINES = 6;
14
16
  function detectRepo(cwd) {
@@ -77,6 +79,8 @@ export class Agent {
77
79
  title = "";
78
80
  createdAt = new Date().toISOString();
79
81
  hooks;
82
+ /** MCP server manager (may be undefined when MCP is disabled). */
83
+ mcp;
80
84
  /** SessionStart fires once, lazily, before the first turn of this instance. */
81
85
  sessionStarted = false;
82
86
  /** carries SessionStart additionalContext into the first user message */
@@ -107,7 +111,16 @@ export class Agent {
107
111
  this.firstMessageSent = this.messages.length > 0;
108
112
  }
109
113
  this.sessionApproveEdits = options.autoApproveEdits;
110
- this.systemPrompt = buildSystemPrompt(options.cwd);
114
+ this.mcp = options.mcp;
115
+ // Build the system prompt with AGENTS.md memory files and the skills
116
+ // catalog injected, so the model sees project/user instructions and
117
+ // knows which skills it can invoke. An explicit override (from
118
+ // `orbcode -s <text>`) bypasses the default entirely.
119
+ const memoryFiles = options.systemPromptOverride ? [] : loadMemoryFiles(options.cwd);
120
+ const skills = options.systemPromptOverride ? new Map() : loadSkills(options.cwd);
121
+ this.systemPrompt = options.systemPromptOverride
122
+ ? options.systemPromptOverride
123
+ : buildSystemPrompt(options.cwd, { memoryFiles, skills });
111
124
  this.client = createLLMClient({
112
125
  token: options.token,
113
126
  modelId: options.modelId,
@@ -353,15 +366,26 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}`;
353
366
  async endSession(reason) {
354
367
  // Let in-flight Notification hooks settle before the final SessionEnd.
355
368
  await this.awaitBackground(3000);
356
- if (!this.hooks.hasHooks("SessionEnd"))
357
- return;
369
+ if (this.hooks.hasHooks("SessionEnd")) {
370
+ try {
371
+ await this.hooks.run("SessionEnd", { reason });
372
+ }
373
+ catch {
374
+ // a SessionEnd hook must never prevent the app from exiting
375
+ }
376
+ }
377
+ // Tear down MCP server connections so child processes / sockets don't leak.
358
378
  try {
359
- await this.hooks.run("SessionEnd", { reason });
379
+ await this.mcp?.stop();
360
380
  }
361
381
  catch {
362
- // a SessionEnd hook must never prevent the app from exiting
382
+ // best-effort
363
383
  }
364
384
  }
385
+ /** The MCP manager (for the TUI's /mcp command and approval flow). */
386
+ get mcpManager() {
387
+ return this.mcp;
388
+ }
365
389
  /** Summarize the conversation so far and replace history with the summary. */
366
390
  async compact() {
367
391
  const { onEvent } = this.options.callbacks;
@@ -452,7 +476,7 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}`;
452
476
  let reasoningDetails;
453
477
  const toolCallsByIndex = new Map();
454
478
  let nextSyntheticIndex = 10000;
455
- const stream = this.client.createMessage(this.systemPrompt, this.outgoingMessages(), getActiveTools(), signal);
479
+ const stream = this.client.createMessage(this.systemPrompt, this.outgoingMessages(), getActiveTools(this.mcp), signal);
456
480
  for await (const chunk of stream) {
457
481
  if (signal.aborted)
458
482
  throw new DOMException("aborted", "AbortError");
@@ -663,7 +687,10 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}`;
663
687
  this.sessionApproveCommands = true;
664
688
  }
665
689
  }
666
- const result = await executeTool(toolCall.name, args, this.toolContext());
690
+ // Yield so the UI can paint the "Working" indicator before a
691
+ // potentially long synchronous tool call blocks the event loop.
692
+ await new Promise(resolve => setImmediate(resolve));
693
+ const result = await executeTool(toolCall.name, args, this.toolContext(), this.mcp);
667
694
  // PostToolUse can feed extra context (or a block reason) back to the
668
695
  // model. PreToolUse additionalContext is delivered here too.
669
696
  let resultText = result.text;
package/dist/headless.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { getModel, isValidAxonModel, usesAiSdk } from "./api/models.js";
2
2
  import { getAuthToken, getPendingProjectHooks, loadSettings } from "./config/settings.js";
3
3
  import { Agent } from "./core/agent.js";
4
+ import { McpManager } from "./mcp/manager.js";
4
5
  /** Non-interactive `orbcode -p "prompt"` mode: prints the final response to stdout. */
5
- export async function runHeadless(prompt, yolo) {
6
+ export async function runHeadless(prompt, yolo, systemPromptOverride) {
6
7
  const settings = loadSettings();
7
8
  // An unknown --model (or MATTERAI_MODEL) silently resolves to the default; say
8
9
  // so on stderr instead of quietly running a different model than requested.
@@ -28,6 +29,21 @@ export async function runHeadless(prompt, yolo) {
28
29
  process.stderr.write(`note: ${pendingHooks.commands.length} project hook(s) in .orbcode/settings.json are untrusted and were skipped. ` +
29
30
  `Trust them in an interactive session, or set MATTERAI_TRUST_PROJECT_HOOKS=1.\n`);
30
31
  }
32
+ // Start MCP servers. In headless mode there's no interactive approval, so
33
+ // project-scope servers are only connected if they were previously approved
34
+ // (persisted in .orbcode/settings.json). Unapproved project servers are
35
+ // skipped with a note, matching the project-hooks behavior.
36
+ const mcp = new McpManager(process.cwd(), settings.disabledMcpServers ?? [], settings.enabledMcpServers ?? []);
37
+ const mcpSnapshot = await mcp.start();
38
+ const pendingMcp = mcp.getPendingApproval();
39
+ if (pendingMcp.length > 0) {
40
+ process.stderr.write(`note: ${pendingMcp.length} project MCP server(s) in .mcp.json are unapproved and were skipped. ` +
41
+ `Approve them in an interactive session with /mcp.\n`);
42
+ }
43
+ const connectedMcp = mcpSnapshot.servers.filter((s) => s.status === "connected").length;
44
+ if (connectedMcp > 0) {
45
+ process.stderr.write(`MCP: ${connectedMcp}/${mcpSnapshot.servers.length} server(s) connected.\n`);
46
+ }
31
47
  let exitCode = 0;
32
48
  // Only the final content is printed: either the attempt_completion result
33
49
  // or, failing that, the last assistant text. Intermediate text and tool
@@ -46,6 +62,8 @@ export async function runHeadless(prompt, yolo) {
46
62
  autoApproveEdits: yolo,
47
63
  autoApproveSafeCommands: yolo,
48
64
  hooks: settings.hooks,
65
+ mcp,
66
+ systemPromptOverride,
49
67
  callbacks: {
50
68
  onEvent: (event) => {
51
69
  switch (event.type) {
@@ -84,6 +102,7 @@ export async function runHeadless(prompt, yolo) {
84
102
  });
85
103
  await agent.runTurn(prompt);
86
104
  await agent.endSession("other");
105
+ await mcp.stop().catch(() => { });
87
106
  const finalContent = completionResult || lastText || textBuffer;
88
107
  if (finalContent) {
89
108
  process.stdout.write(finalContent.trimEnd() + "\n");
package/dist/index.js CHANGED
@@ -3,6 +3,7 @@ import { render } from "ink";
3
3
  import { PRODUCT_NAME, VERSION } from "./branding.js";
4
4
  import { App } from "./ui/App.js";
5
5
  import { runHeadless } from "./headless.js";
6
+ import { runMcpCommand } from "./commands/mcp.js";
6
7
  import { loadSessionById } from "./core/sessions.js";
7
8
  import { clearUpdateCache, compareVersions, fetchLatestNpmVersion, getGlobalInstallRoot, getUpdateInfo, isGlobalInstall, runNpmUpdate, } from "./utils/updateCheck.js";
8
9
  const PACKAGE_NAME = "@matterailab/orbcode";
@@ -15,10 +16,16 @@ Usage:
15
16
  orbcode login sign in to MatterAI
16
17
  orbcode update install the latest version from npm
17
18
  orbcode update --force force a global install even if this CLI doesn't look global
19
+ orbcode mcp add ... add an MCP server (see: orbcode mcp help)
20
+ orbcode mcp remove ... remove an MCP server
21
+ orbcode mcp list list configured MCP servers
18
22
  orbcode -p "<prompt>" run a single prompt non-interactively (prints only the final response)
19
23
  orbcode -p "…" --yolo non-interactive with auto-approved edits/commands
20
24
  orbcode --model <id> use a specific model for this run
21
25
  orbcode --resume <id> resume a previous session by id
26
+ orbcode -s "<prompt>" override the default system prompt (replaces it entirely;
27
+ accepts -s <text>, --system-prompt <text>, and
28
+ --system-prompt=<text> for values that start with '-')
22
29
  orbcode --version print version
23
30
  orbcode --help show this help
24
31
  `);
@@ -67,6 +74,33 @@ function takeFlagValue(args, name) {
67
74
  args.splice(index, 2);
68
75
  return value;
69
76
  }
77
+ /**
78
+ * Pop the `-s` / `--system-prompt` flag (and its value) out of `args`.
79
+ * Unlike `takeFlagValue` this accepts a value that starts with `-`, because
80
+ * a system-prompt override is free-form text. Supports three forms:
81
+ * -s "<text>" --value in next arg
82
+ * --system-prompt "<text>"
83
+ * --system-prompt="<text>"
84
+ * --system-prompt=-<text>
85
+ */
86
+ function takeSystemPrompt(args) {
87
+ const longWithEq = args.findIndex((a) => a.startsWith("--system-prompt="));
88
+ if (longWithEq !== -1) {
89
+ const value = args[longWithEq].slice("--system-prompt=".length);
90
+ args.splice(longWithEq, 1);
91
+ return value.length > 0 ? value : undefined;
92
+ }
93
+ const index = args.findIndex((a) => a === "-s" || a === "--system-prompt");
94
+ if (index === -1)
95
+ return undefined;
96
+ const value = args[index + 1];
97
+ if (value === undefined) {
98
+ console.error("Missing value after -s / --system-prompt");
99
+ process.exit(1);
100
+ }
101
+ args.splice(index, 2);
102
+ return value;
103
+ }
70
104
  async function main() {
71
105
  // Override Node's default process title so terminals (iTerm2 "current job
72
106
  // name", VSCode terminal status, etc.) don't append " (node)" next to our
@@ -86,6 +120,11 @@ async function main() {
86
120
  const code = await runUpdate(force);
87
121
  process.exit(code);
88
122
  }
123
+ // `orbcode mcp ...` — manage MCP servers from the command line (add/remove/list).
124
+ if (args[0] === "mcp") {
125
+ const code = await runMcpCommand(args.slice(1));
126
+ process.exit(code);
127
+ }
89
128
  const model = takeFlagValue(args, "model") ?? takeFlagValue(args, "m");
90
129
  if (model) {
91
130
  // loadSettings() treats MATTERAI_MODEL as the highest-precedence override,
@@ -101,6 +140,10 @@ async function main() {
101
140
  process.exit(1);
102
141
  }
103
142
  }
143
+ // `-s` / `--system-prompt` lets the user replace the default prompt
144
+ // entirely. Accepts values starting with `-` (unlike other flags), so
145
+ // `orbcode -s '- you are a code reviewer'` works.
146
+ const systemPromptOverride = takeSystemPrompt(args);
104
147
  const printIndex = args.findIndex((a) => a === "-p" || a === "--print");
105
148
  if (printIndex !== -1) {
106
149
  const prompt = args[printIndex + 1];
@@ -108,7 +151,7 @@ async function main() {
108
151
  console.error("Missing prompt after -p");
109
152
  process.exit(1);
110
153
  }
111
- await runHeadless(prompt, args.includes("--yolo"));
154
+ await runHeadless(prompt, args.includes("--yolo"), systemPromptOverride);
112
155
  return;
113
156
  }
114
157
  const initialView = args[0] === "login" ? "login" : undefined;
@@ -123,7 +166,7 @@ async function main() {
123
166
  // Fire-and-forget: a stale or no-network state just means the header shows
124
167
  // the "current version" line instead of an upgrade prompt.
125
168
  const updateCheckPromise = getUpdateInfo(PACKAGE_NAME, VERSION);
126
- render(_jsx(App, { initialView: initialView, initialPrompt: initialPrompt, initialSession: initialSession, updateCheck: updateCheckPromise }));
169
+ render(_jsx(App, { initialView: initialView, initialPrompt: initialPrompt, initialSession: initialSession, systemPromptOverride: systemPromptOverride, updateCheck: updateCheckPromise }));
127
170
  }
128
171
  main().catch((error) => {
129
172
  console.error(error);
@@ -0,0 +1,289 @@
1
+ import * as http from "node:http";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import open from "open";
5
+ import { ClientCredentialsProvider, PrivateKeyJwtProvider, } from "@modelcontextprotocol/sdk/client/auth-extensions.js";
6
+ import { auth, } from "@modelcontextprotocol/sdk/client/auth.js";
7
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
8
+ import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
9
+ import { getConfigDir } from "../config/settings.js";
10
+ /**
11
+ * MCP OAuth support.
12
+ *
13
+ * Remote MCP servers (http/sse) often require OAuth. The SDK ships a full
14
+ * OAuth client (RFC 9728 discovery + RFC 8414 metadata + PKCE + dynamic client
15
+ * registration + token refresh), gated behind an `OAuthClientProvider` that
16
+ * persists per-server state. This module provides:
17
+ *
18
+ * - `FileOAuthProvider`: a filesystem-backed provider that stores tokens,
19
+ * client info, code verifiers, and discovery state per server under
20
+ * `~/.orbcode/mcp-auth/<server>.json`.
21
+ * - `startCallbackServer`: a one-shot loopback HTTP server that receives the
22
+ * OAuth redirect and resolves with the authorization code.
23
+ * - `buildAuthProvider`: turns an `McpOAuthConfig` into an `OAuthClientProvider`
24
+ * (interactive auth-code flow, or M2M client_credentials / private_key_jwt).
25
+ * - `connectWithAuth`: wraps the transport connect + auth retry loop.
26
+ */
27
+ const CLIENT_METADATA = {
28
+ client_name: "OrbCode CLI",
29
+ redirect_uris: [], // filled in per-callback-port at runtime
30
+ grant_types: ["authorization_code", "refresh_token"],
31
+ token_endpoint_auth_method: "none",
32
+ scope: "",
33
+ };
34
+ /** Directory holding per-server OAuth state files. */
35
+ function authDir() {
36
+ return path.join(getConfigDir(), "mcp-auth");
37
+ }
38
+ /** Path to a server's persistent OAuth store. */
39
+ function authFilePath(serverName) {
40
+ return path.join(authDir(), `${serverName}.json`);
41
+ }
42
+ /** Read a server's OAuth store (or undefined if none). */
43
+ function readAuthStore(serverName) {
44
+ try {
45
+ return JSON.parse(fs.readFileSync(authFilePath(serverName), "utf8"));
46
+ }
47
+ catch {
48
+ return undefined;
49
+ }
50
+ }
51
+ /** Write a server's OAuth store (best-effort). */
52
+ function writeAuthStore(serverName, store) {
53
+ try {
54
+ fs.mkdirSync(authDir(), { recursive: true });
55
+ fs.writeFileSync(authFilePath(serverName), JSON.stringify(store, null, "\t") + "\n", {
56
+ mode: 0o600,
57
+ });
58
+ }
59
+ catch {
60
+ // best-effort; auth will just re-prompt next time
61
+ }
62
+ }
63
+ /** A filesystem-backed OAuthClientProvider for one MCP server. */
64
+ class FileOAuthProvider {
65
+ serverName;
66
+ callbackPort;
67
+ scope;
68
+ store;
69
+ /** The authorization URL captured from the last redirectToAuthorization call. */
70
+ authUrl;
71
+ /** Resolved by redirectToAuthorization; awaited by the connect loop. */
72
+ codeResolve;
73
+ constructor(serverName, callbackPort, scope) {
74
+ this.serverName = serverName;
75
+ this.callbackPort = callbackPort;
76
+ this.scope = scope;
77
+ this.store = readAuthStore(serverName) ?? {};
78
+ }
79
+ get redirectUrl() {
80
+ return `http://localhost:${this.callbackPort}/callback`;
81
+ }
82
+ get clientMetadata() {
83
+ return { ...CLIENT_METADATA, redirect_uris: [this.redirectUrl], scope: this.scope ?? "" };
84
+ }
85
+ clientInformation() {
86
+ return this.store.clientInformation;
87
+ }
88
+ saveClientInformation(info) {
89
+ this.store.clientInformation = info;
90
+ writeAuthStore(this.serverName, this.store);
91
+ }
92
+ tokens() {
93
+ return this.store.tokens;
94
+ }
95
+ saveTokens(tokens) {
96
+ this.store.tokens = tokens;
97
+ writeAuthStore(this.serverName, this.store);
98
+ }
99
+ saveCodeVerifier(verifier) {
100
+ this.store.codeVerifier = verifier;
101
+ writeAuthStore(this.serverName, this.store);
102
+ }
103
+ codeVerifier() {
104
+ if (!this.store.codeVerifier)
105
+ throw new Error("No PKCE code verifier stored.");
106
+ return this.store.codeVerifier;
107
+ }
108
+ saveDiscoveryState(state) {
109
+ this.store.discoveryState = state;
110
+ writeAuthStore(this.serverName, this.store);
111
+ }
112
+ discoveryState() {
113
+ return this.store.discoveryState;
114
+ }
115
+ invalidateCredentials(scope) {
116
+ if (scope === "all")
117
+ this.store = {};
118
+ else if (scope === "client")
119
+ delete this.store.clientInformation;
120
+ else if (scope === "tokens")
121
+ delete this.store.tokens;
122
+ else if (scope === "verifier")
123
+ delete this.store.codeVerifier;
124
+ else if (scope === "discovery")
125
+ delete this.store.discoveryState;
126
+ writeAuthStore(this.serverName, this.store);
127
+ }
128
+ /** Open the user's browser to the authorization URL and capture it so the
129
+ * caller can surface it in the TUI (with copy + paste fallback). */
130
+ redirectToAuthorization(authorizationUrl) {
131
+ this.authUrl = authorizationUrl.toString();
132
+ void open(this.authUrl).catch(() => {
133
+ // best-effort; the user can copy the URL from the TUI
134
+ });
135
+ }
136
+ /** The authorization URL captured from the last redirect (for TUI display). */
137
+ getAuthUrl() {
138
+ return this.authUrl;
139
+ }
140
+ /** Called by the callback server when the OAuth redirect lands. */
141
+ resolveCode(code) {
142
+ this.codeResolve?.(code);
143
+ }
144
+ /** Await the authorization code from the browser redirect or a manual paste. */
145
+ waitForCode() {
146
+ return new Promise((resolve) => {
147
+ this.codeResolve = resolve;
148
+ });
149
+ }
150
+ }
151
+ /** Start a one-shot loopback HTTP server to receive the OAuth redirect. */
152
+ function startCallbackServer(port, onCode) {
153
+ const server = http.createServer((req, res) => {
154
+ const url = new URL(req.url ?? "/", `http://localhost:${port}`);
155
+ if (url.pathname !== "/callback") {
156
+ res.writeHead(404).end("not found");
157
+ return;
158
+ }
159
+ const code = url.searchParams.get("code");
160
+ const error = url.searchParams.get("error");
161
+ if (code) {
162
+ onCode(code);
163
+ res.writeHead(200, { "Content-Type": "text/html" });
164
+ res.end("<h1>Authorized</h1><p>You can close this tab and return to OrbCode.</p>");
165
+ }
166
+ else if (error) {
167
+ res.writeHead(400, { "Content-Type": "text/html" });
168
+ res.end(`<h1>Authorization failed</h1><p>${error}</p>`);
169
+ }
170
+ else {
171
+ res.writeHead(400, { "Content-Type": "text/html" });
172
+ res.end("<h1>Missing code</h1>");
173
+ }
174
+ // Close after responding so the port is freed immediately.
175
+ server.close();
176
+ });
177
+ server.listen(port, "127.0.0.1");
178
+ return server;
179
+ }
180
+ /** Pick an ephemeral loopback port for the callback server. */
181
+ function ephemeralPort() {
182
+ // Bind to port 0 and read back the assigned port, then close immediately.
183
+ const probe = http.createServer().listen(0, "127.0.0.1");
184
+ const addr = probe.address();
185
+ probe.close();
186
+ return typeof addr === "object" && addr ? addr.port : 8765;
187
+ }
188
+ /** Build an OAuthClientProvider from an McpOAuthConfig. Returns the provider
189
+ * plus a callback-port hint (for the interactive flow). */
190
+ export function buildAuthProvider(serverName, oauth) {
191
+ // M2M: client_credentials grant (no browser).
192
+ if (typeof oauth === "object" && "grantType" in oauth && oauth.grantType === "client_credentials") {
193
+ return {
194
+ provider: new ClientCredentialsProvider({
195
+ clientId: oauth.clientId,
196
+ clientSecret: oauth.clientSecret,
197
+ scope: oauth.scope,
198
+ clientName: "OrbCode CLI",
199
+ }),
200
+ };
201
+ }
202
+ // M2M: private_key_jwt assertion (no browser).
203
+ if (typeof oauth === "object" && "grantType" in oauth && oauth.grantType === "private_key_jwt") {
204
+ return {
205
+ provider: new PrivateKeyJwtProvider({
206
+ clientId: oauth.clientId,
207
+ privateKey: oauth.privateKey,
208
+ algorithm: oauth.algorithm,
209
+ scope: oauth.scope,
210
+ clientName: "OrbCode CLI",
211
+ }),
212
+ };
213
+ }
214
+ // Interactive authorization-code flow (browser redirect).
215
+ const scope = typeof oauth === "object" ? oauth.scope : undefined;
216
+ const port = ephemeralPort();
217
+ return { provider: new FileOAuthProvider(serverName, port, scope), callbackPort: port };
218
+ }
219
+ /** True if the config requests OAuth (vs. static headers). */
220
+ export function hasOAuth(config) {
221
+ return config.oauth !== undefined && config.oauth !== false;
222
+ }
223
+ /** True if a server config is http/sse with OAuth enabled. */
224
+ export function isOAuthConfig(config) {
225
+ return (config.type === "http" || config.type === "sse") && hasOAuth(config);
226
+ }
227
+ /** True if this server has persisted OAuth tokens (i.e. already authenticated). */
228
+ export function hasStoredAuth(serverName) {
229
+ const store = readAuthStore(serverName);
230
+ return Boolean(store && store.tokens && store.tokens.access_token);
231
+ }
232
+ /** Create an http/sse transport with an auth provider. The `authenticate()`
233
+ * function uses the standalone `auth()` orchestrator to obtain tokens BEFORE
234
+ * the transport is started (by client.connect()). This avoids the
235
+ * "StreamableHTTPClientTransport already started" error that occurs when
236
+ * transport.start() is called both by authenticate() and by client.connect().
237
+ *
238
+ * When `intercept` is provided, the caller controls how the auth URL is shown
239
+ * and how the code is obtained (TUI with copy + paste fallback). When omitted,
240
+ * the flow auto-waits for the loopback callback server (headless mode). */
241
+ export function createAuthTransport(serverName, url, kind, oauth, requestInit, intercept) {
242
+ const { provider, callbackPort } = buildAuthProvider(serverName, oauth);
243
+ const fileProvider = provider instanceof FileOAuthProvider ? provider : undefined;
244
+ const transport = kind === "http"
245
+ ? new StreamableHTTPClientTransport(url, { authProvider: provider, requestInit })
246
+ : new SSEClientTransport(url, { authProvider: provider, requestInit });
247
+ const authenticate = async () => {
248
+ // M2M providers (ClientCredentialsProvider, PrivateKeyJwtProvider) don't
249
+ // need a browser — the auth() call handles token acquisition directly.
250
+ if (!fileProvider) {
251
+ const result = await auth(provider, { serverUrl: url });
252
+ if (result === "REDIRECT") {
253
+ throw new Error("M2M provider requested a browser redirect — unexpected.");
254
+ }
255
+ return;
256
+ }
257
+ // Interactive flow: use the standalone auth() orchestrator.
258
+ // If we already have valid tokens, auth() returns AUTHORIZED immediately.
259
+ // If not, it returns REDIRECT (after calling redirectToAuthorization,
260
+ // which opens the browser and captures the URL).
261
+ const serverUrlStr = url.toString();
262
+ const result = await auth(provider, { serverUrl: serverUrlStr });
263
+ if (result === "AUTHORIZED")
264
+ return; // tokens are valid
265
+ // REDIRECT: the provider's redirectToAuthorization was called, which
266
+ // captured the auth URL and opened the browser. Surface it to the TUI.
267
+ const authUrl = fileProvider.getAuthUrl();
268
+ if (intercept && authUrl)
269
+ intercept.onAuthUrl(authUrl);
270
+ // Start the callback server (the browser redirect may arrive here).
271
+ const callbackServer = startCallbackServer(callbackPort, (code) => fileProvider.resolveCode(code));
272
+ try {
273
+ let code;
274
+ if (intercept) {
275
+ // Interactive: race the callback against a manual paste.
276
+ code = await Promise.race([intercept.getCode(), fileProvider.waitForCode()]);
277
+ }
278
+ else {
279
+ code = await fileProvider.waitForCode();
280
+ }
281
+ // Exchange the code for tokens via the standalone auth() call.
282
+ await auth(provider, { serverUrl: serverUrlStr, authorizationCode: code });
283
+ }
284
+ finally {
285
+ callbackServer.close();
286
+ }
287
+ };
288
+ return { transport, authenticate, provider: fileProvider };
289
+ }