@matterailab/orbcode 0.2.2 → 0.2.3

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,13 @@ 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.
118
+ const memoryFiles = loadMemoryFiles(options.cwd);
119
+ const skills = loadSkills(options.cwd);
120
+ this.systemPrompt = buildSystemPrompt(options.cwd, { memoryFiles, skills });
111
121
  this.client = createLLMClient({
112
122
  token: options.token,
113
123
  modelId: options.modelId,
@@ -353,15 +363,26 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}`;
353
363
  async endSession(reason) {
354
364
  // Let in-flight Notification hooks settle before the final SessionEnd.
355
365
  await this.awaitBackground(3000);
356
- if (!this.hooks.hasHooks("SessionEnd"))
357
- return;
366
+ if (this.hooks.hasHooks("SessionEnd")) {
367
+ try {
368
+ await this.hooks.run("SessionEnd", { reason });
369
+ }
370
+ catch {
371
+ // a SessionEnd hook must never prevent the app from exiting
372
+ }
373
+ }
374
+ // Tear down MCP server connections so child processes / sockets don't leak.
358
375
  try {
359
- await this.hooks.run("SessionEnd", { reason });
376
+ await this.mcp?.stop();
360
377
  }
361
378
  catch {
362
- // a SessionEnd hook must never prevent the app from exiting
379
+ // best-effort
363
380
  }
364
381
  }
382
+ /** The MCP manager (for the TUI's /mcp command and approval flow). */
383
+ get mcpManager() {
384
+ return this.mcp;
385
+ }
365
386
  /** Summarize the conversation so far and replace history with the summary. */
366
387
  async compact() {
367
388
  const { onEvent } = this.options.callbacks;
@@ -452,7 +473,7 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}`;
452
473
  let reasoningDetails;
453
474
  const toolCallsByIndex = new Map();
454
475
  let nextSyntheticIndex = 10000;
455
- const stream = this.client.createMessage(this.systemPrompt, this.outgoingMessages(), getActiveTools(), signal);
476
+ const stream = this.client.createMessage(this.systemPrompt, this.outgoingMessages(), getActiveTools(this.mcp), signal);
456
477
  for await (const chunk of stream) {
457
478
  if (signal.aborted)
458
479
  throw new DOMException("aborted", "AbortError");
@@ -663,7 +684,7 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}`;
663
684
  this.sessionApproveCommands = true;
664
685
  }
665
686
  }
666
- const result = await executeTool(toolCall.name, args, this.toolContext());
687
+ const result = await executeTool(toolCall.name, args, this.toolContext(), this.mcp);
667
688
  // PostToolUse can feed extra context (or a block reason) back to the
668
689
  // model. PreToolUse additionalContext is delivered here too.
669
690
  let resultText = result.text;
package/dist/headless.js CHANGED
@@ -1,6 +1,7 @@
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
6
  export async function runHeadless(prompt, yolo) {
6
7
  const settings = loadSettings();
@@ -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,7 @@ export async function runHeadless(prompt, yolo) {
46
62
  autoApproveEdits: yolo,
47
63
  autoApproveSafeCommands: yolo,
48
64
  hooks: settings.hooks,
65
+ mcp,
49
66
  callbacks: {
50
67
  onEvent: (event) => {
51
68
  switch (event.type) {
@@ -84,6 +101,7 @@ export async function runHeadless(prompt, yolo) {
84
101
  });
85
102
  await agent.runTurn(prompt);
86
103
  await agent.endSession("other");
104
+ await mcp.stop().catch(() => { });
87
105
  const finalContent = completionResult || lastText || textBuffer;
88
106
  if (finalContent) {
89
107
  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,6 +16,9 @@ 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
@@ -86,6 +90,11 @@ async function main() {
86
90
  const code = await runUpdate(force);
87
91
  process.exit(code);
88
92
  }
93
+ // `orbcode mcp ...` — manage MCP servers from the command line (add/remove/list).
94
+ if (args[0] === "mcp") {
95
+ const code = await runMcpCommand(args.slice(1));
96
+ process.exit(code);
97
+ }
89
98
  const model = takeFlagValue(args, "model") ?? takeFlagValue(args, "m");
90
99
  if (model) {
91
100
  // loadSettings() treats MATTERAI_MODEL as the highest-precedence override,
@@ -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
+ }
@@ -0,0 +1,132 @@
1
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
+ import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
3
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
4
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
5
+ import { VERSION } from "../branding.js";
6
+ import { createAuthTransport, hasOAuth } from "./auth.js";
7
+ /** Build the SDK transport for a server config. For http/sse servers with an
8
+ * `oauth` block, returns an auth-aware transport whose `authenticate()` runs
9
+ * the OAuth flow (browser redirect or M2M) before `start()`. */
10
+ function buildTransport(name, config, intercept) {
11
+ if (config.type === "sse") {
12
+ if (hasOAuth(config)) {
13
+ const auth = createAuthTransport(name, new URL(config.url), "sse", config.oauth, {
14
+ headers: config.headers ?? {},
15
+ }, intercept);
16
+ return { transport: auth.transport, authenticate: auth.authenticate };
17
+ }
18
+ return {
19
+ transport: new SSEClientTransport(new URL(config.url), {
20
+ requestInit: { headers: config.headers ?? {} },
21
+ }),
22
+ };
23
+ }
24
+ if (config.type === "http") {
25
+ if (hasOAuth(config)) {
26
+ const auth = createAuthTransport(name, new URL(config.url), "http", config.oauth, {
27
+ headers: config.headers ?? {},
28
+ }, intercept);
29
+ return { transport: auth.transport, authenticate: auth.authenticate };
30
+ }
31
+ return {
32
+ transport: new StreamableHTTPClientTransport(new URL(config.url), {
33
+ requestInit: { headers: config.headers ?? {} },
34
+ }),
35
+ };
36
+ }
37
+ // stdio (default)
38
+ return {
39
+ transport: new StdioClientTransport({
40
+ command: config.command,
41
+ args: config.args ?? [],
42
+ env: config.env,
43
+ cwd: config.cwd,
44
+ stderr: "pipe",
45
+ }),
46
+ };
47
+ }
48
+ const CONNECT_TIMEOUT_MS = 30_000;
49
+ const CLIENT_INFO = { name: "orbcode", version: VERSION };
50
+ /** Connect to one MCP server and enumerate its tools. The optional
51
+ * `authIntercept` lets the caller surface the OAuth URL in the TUI and
52
+ * provide the auth code (from the callback or a manual paste). */
53
+ export async function connectMcpServer(name, config, authIntercept) {
54
+ const { transport, authenticate } = buildTransport(name, config, authIntercept);
55
+ const client = new Client(CLIENT_INFO, { capabilities: {} });
56
+ // For OAuth servers, run the auth flow (browser redirect or M2M) before
57
+ // connecting. This may open a browser and block until the user authorizes.
58
+ if (authenticate) {
59
+ await withTimeout(authenticate(), CONNECT_TIMEOUT_MS, `MCP server "${name}" auth timed out`);
60
+ }
61
+ // Wrap connect in a timeout so a hung server doesn't block startup.
62
+ await withTimeout(client.connect(transport), CONNECT_TIMEOUT_MS, `MCP server "${name}" timed out`);
63
+ const tools = await listServerTools(name, client);
64
+ return {
65
+ client,
66
+ tools,
67
+ close: async () => {
68
+ try {
69
+ await transport.close();
70
+ }
71
+ catch {
72
+ // best-effort
73
+ }
74
+ try {
75
+ await client.close();
76
+ }
77
+ catch {
78
+ // best-effort
79
+ }
80
+ },
81
+ };
82
+ }
83
+ /** Enumerate tools from a connected client, namespaced as mcp__<server>__<tool>. */
84
+ export async function listServerTools(server, client) {
85
+ const result = await client.listTools();
86
+ return (result.tools ?? []).map((tool) => ({
87
+ name: `mcp__${server}__${tool.name}`,
88
+ originalName: tool.name,
89
+ server,
90
+ description: tool.description,
91
+ inputSchema: tool.inputSchema ?? { type: "object", properties: {} },
92
+ }));
93
+ }
94
+ /** Call a tool on a connected client and return a text result for the model. */
95
+ export async function callMcpTool(connection, originalName, args) {
96
+ const result = await connection.client.callTool({ name: originalName, arguments: args });
97
+ const content = (result.content ?? []);
98
+ const parts = [];
99
+ for (const block of content) {
100
+ const type = block.type;
101
+ if (type === "text" && typeof block.text === "string") {
102
+ parts.push(block.text);
103
+ }
104
+ else if (type === "resource") {
105
+ const resource = block.resource;
106
+ if (resource && typeof resource.text === "string")
107
+ parts.push(resource.text);
108
+ }
109
+ else if (type === "image") {
110
+ parts.push(`[image: ${block.mimeType}]`);
111
+ }
112
+ else if (type === "audio") {
113
+ parts.push(`[audio: ${block.mimeType}]`);
114
+ }
115
+ else if (type === "resource_link" && typeof block.uri === "string") {
116
+ parts.push(`[resource: ${block.uri}]`);
117
+ }
118
+ }
119
+ const text = parts.join("\n") || "(empty MCP tool result)";
120
+ return { text, isError: Boolean(result.isError) };
121
+ }
122
+ function withTimeout(promise, ms, message) {
123
+ let timer;
124
+ const cap = new Promise((_, reject) => {
125
+ timer = setTimeout(() => reject(new Error(message)), ms);
126
+ timer.unref?.();
127
+ });
128
+ return Promise.race([promise, cap]).finally(() => {
129
+ if (timer)
130
+ clearTimeout(timer);
131
+ });
132
+ }