@matterailab/orbcode 0.2.1 → 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.
@@ -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
+ }
@@ -0,0 +1,270 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { getConfigDir } from "../config/settings.js";
4
+ /**
5
+ * MCP server configuration loader.
6
+ *
7
+ * Resolution order (lowest precedence first), mirroring Claude Code:
8
+ * 1. User scope: `~/.orbcode/settings.json` -> `mcpServers`
9
+ * 2. Project scope: `.mcp.json` in the cwd and every parent directory
10
+ * (closer-to-cwd wins on name collisions)
11
+ * 3. Local scope: `.orbcode/settings.json` -> `mcpServers` (highest precedence)
12
+ *
13
+ * `.mcp.json` is the shared, check-into-git project file (Claude Code compatible).
14
+ * The `mcpServers` block in settings.json is the per-user/per-machine override.
15
+ */
16
+ function readJson(filePath) {
17
+ try {
18
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
19
+ }
20
+ catch {
21
+ return undefined;
22
+ }
23
+ }
24
+ /** Expand ${VAR} and $VAR references in a string using process.env. */
25
+ function expandEnv(value) {
26
+ return value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g, (_, braced, bare) => {
27
+ const name = braced ?? bare;
28
+ return process.env[name] ?? "";
29
+ });
30
+ }
31
+ function isPlainObject(value) {
32
+ return typeof value === "object" && value !== null && !Array.isArray(value);
33
+ }
34
+ /** Normalize an `oauth` config value: boolean, or an object with grant/scope. */
35
+ function normalizeOAuth(raw) {
36
+ if (raw === true)
37
+ return true;
38
+ if (typeof raw === "string" && raw === "true")
39
+ return true;
40
+ if (!isPlainObject(raw))
41
+ return undefined;
42
+ if (raw.grantType === "client_credentials") {
43
+ if (typeof raw.clientId !== "string" || typeof raw.clientSecret !== "string")
44
+ return undefined;
45
+ return {
46
+ grantType: "client_credentials",
47
+ clientId: String(raw.clientId),
48
+ clientSecret: String(raw.clientSecret),
49
+ scope: typeof raw.scope === "string" ? raw.scope : undefined,
50
+ };
51
+ }
52
+ if (raw.grantType === "private_key_jwt") {
53
+ if (typeof raw.clientId !== "string")
54
+ return undefined;
55
+ if (typeof raw.algorithm !== "string")
56
+ return undefined;
57
+ const privateKey = typeof raw.privateKey === "string" ? raw.privateKey : isPlainObject(raw.privateKey) ? raw.privateKey : undefined;
58
+ if (privateKey === undefined)
59
+ return undefined;
60
+ return {
61
+ grantType: "private_key_jwt",
62
+ clientId: String(raw.clientId),
63
+ privateKey,
64
+ algorithm: String(raw.algorithm),
65
+ scope: typeof raw.scope === "string" ? raw.scope : undefined,
66
+ };
67
+ }
68
+ // Plain object (no grantType) → interactive auth-code flow with optional scope.
69
+ return { scope: typeof raw.scope === "string" ? raw.scope : undefined };
70
+ }
71
+ /** Validate + normalize a single server config object from disk. */
72
+ function normalizeServerConfig(raw) {
73
+ if (!isPlainObject(raw))
74
+ return undefined;
75
+ const type = typeof raw.type === "string" ? raw.type : "stdio";
76
+ if (type === "stdio") {
77
+ if (typeof raw.command !== "string" || !raw.command.trim())
78
+ return undefined;
79
+ const config = { type: "stdio", command: expandEnv(raw.command) };
80
+ if (Array.isArray(raw.args)) {
81
+ config.args = raw.args.map((a) => expandEnv(String(a)));
82
+ }
83
+ if (isPlainObject(raw.env)) {
84
+ const env = {};
85
+ for (const [k, v] of Object.entries(raw.env)) {
86
+ if (typeof v === "string")
87
+ env[k] = expandEnv(v);
88
+ }
89
+ config.env = env;
90
+ }
91
+ if (typeof raw.cwd === "string")
92
+ config.cwd = expandEnv(raw.cwd);
93
+ return config;
94
+ }
95
+ if (type === "http" || type === "sse") {
96
+ if (typeof raw.url !== "string" || !raw.url.trim())
97
+ return undefined;
98
+ const config = {
99
+ type,
100
+ url: expandEnv(raw.url),
101
+ };
102
+ if (isPlainObject(raw.headers)) {
103
+ const headers = {};
104
+ for (const [k, v] of Object.entries(raw.headers)) {
105
+ if (typeof v === "string")
106
+ headers[k] = expandEnv(v);
107
+ }
108
+ ;
109
+ config.headers = headers;
110
+ }
111
+ const oauth = normalizeOAuth(raw.oauth);
112
+ if (oauth !== undefined)
113
+ config.oauth = oauth;
114
+ return config;
115
+ }
116
+ return undefined;
117
+ }
118
+ /** Validate + normalize a whole `mcpServers` record. */
119
+ function normalizeServers(raw) {
120
+ if (!isPlainObject(raw))
121
+ return {};
122
+ const servers = {};
123
+ for (const [name, cfg] of Object.entries(raw)) {
124
+ if (!/^[A-Za-z0-9_-]+$/.test(name))
125
+ continue;
126
+ const normalized = normalizeServerConfig(cfg);
127
+ if (normalized)
128
+ servers[name] = normalized;
129
+ }
130
+ return servers;
131
+ }
132
+ /** Read `mcpServers` from a settings.json file (user or local scope). */
133
+ function readSettingsServers(filePath) {
134
+ const json = readJson(filePath);
135
+ if (!json)
136
+ return {};
137
+ return normalizeServers(json.mcpServers);
138
+ }
139
+ /** Read a `.mcp.json` file (project scope). */
140
+ function readMcpJson(filePath) {
141
+ const json = readJson(filePath);
142
+ if (!json || !isPlainObject(json.mcpServers))
143
+ return {};
144
+ return normalizeServers(json.mcpServers);
145
+ }
146
+ /** Walk from cwd up to the filesystem root, collecting directories (root last). */
147
+ function ancestorDirs(cwd) {
148
+ const dirs = [];
149
+ let current = path.resolve(cwd);
150
+ const root = path.parse(current).root;
151
+ while (current !== root) {
152
+ dirs.push(current);
153
+ const parent = path.dirname(current);
154
+ if (parent === current)
155
+ break;
156
+ current = parent;
157
+ }
158
+ return dirs;
159
+ }
160
+ /**
161
+ * Load and merge MCP server configs from all scopes. Closer-to-cwd and
162
+ * higher-precedence scopes override earlier ones on name collisions.
163
+ */
164
+ export function loadMcpConfig(cwd = process.cwd()) {
165
+ const servers = {};
166
+ // 1. User scope (~/.orbcode/settings.json -> mcpServers)
167
+ const userServers = readSettingsServers(path.join(getConfigDir(), "settings.json"));
168
+ for (const [name, cfg] of Object.entries(userServers)) {
169
+ servers[name] = { ...cfg, scope: "user" };
170
+ }
171
+ // 2. Project scope (.mcp.json, root -> cwd so cwd wins)
172
+ for (const dir of ancestorDirs(cwd).reverse()) {
173
+ const projectServers = readMcpJson(path.join(dir, ".mcp.json"));
174
+ for (const [name, cfg] of Object.entries(projectServers)) {
175
+ servers[name] = { ...cfg, scope: "project" };
176
+ }
177
+ }
178
+ // 3. Local scope (.orbcode/settings.json -> mcpServers) — highest precedence
179
+ const localServers = readSettingsServers(path.join(cwd, ".orbcode", "settings.json"));
180
+ for (const [name, cfg] of Object.entries(localServers)) {
181
+ servers[name] = { ...cfg, scope: "local" };
182
+ }
183
+ return { servers };
184
+ }
185
+ /** Write a `.mcp.json` file in the cwd (project scope). */
186
+ export function writeProjectMcpJson(cwd, servers) {
187
+ const config = { mcpServers: servers };
188
+ fs.writeFileSync(path.join(cwd, ".mcp.json"), JSON.stringify(config, null, "\t") + "\n");
189
+ }
190
+ /** Read just the project-scope `.mcp.json` from the cwd (no parent walk). */
191
+ export function readProjectMcpJson(cwd) {
192
+ return readMcpJson(path.join(cwd, ".mcp.json"));
193
+ }
194
+ /** Read a settings.json file, returning the full object (not just mcpServers). */
195
+ function readFullSettings(filePath) {
196
+ return readJson(filePath) ?? {};
197
+ }
198
+ /** Write a settings.json file, preserving existing keys. */
199
+ function writeFullSettings(filePath, data) {
200
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
201
+ fs.writeFileSync(filePath, JSON.stringify(data, null, "\t") + "\n", { mode: 0o600 });
202
+ }
203
+ /** Path to the settings file for a given scope. */
204
+ function settingsPathForScope(cwd, scope) {
205
+ if (scope === "user")
206
+ return path.join(getConfigDir(), "settings.json");
207
+ return path.join(cwd, ".orbcode", "settings.json");
208
+ }
209
+ /** Path to the config file for a given scope (`.mcp.json` for project,
210
+ * settings.json for user/local). */
211
+ export function configPathForScope(cwd, scope) {
212
+ if (scope === "project")
213
+ return path.join(cwd, ".mcp.json");
214
+ return settingsPathForScope(cwd, scope);
215
+ }
216
+ /** Add (or overwrite) a server in the given scope's config file. */
217
+ export function addMcpServer(cwd, name, config, scope) {
218
+ if (!/^[A-Za-z0-9_-]+$/.test(name)) {
219
+ throw new Error(`Invalid server name "${name}". Use only letters, numbers, hyphens, and underscores.`);
220
+ }
221
+ if (scope === "project") {
222
+ const existing = readProjectMcpJson(cwd);
223
+ existing[name] = config;
224
+ writeProjectMcpJson(cwd, existing);
225
+ }
226
+ else {
227
+ const filePath = settingsPathForScope(cwd, scope);
228
+ const settings = readFullSettings(filePath);
229
+ const servers = normalizeServers(settings.mcpServers);
230
+ servers[name] = config;
231
+ settings.mcpServers = servers;
232
+ writeFullSettings(filePath, settings);
233
+ }
234
+ }
235
+ /** Remove a server from the given scope's config file. Returns true if it
236
+ * existed and was removed, false if it wasn't found. */
237
+ export function removeMcpServer(cwd, name, scope) {
238
+ if (scope === "project") {
239
+ const existing = readProjectMcpJson(cwd);
240
+ if (!(name in existing))
241
+ return false;
242
+ delete existing[name];
243
+ writeProjectMcpJson(cwd, existing);
244
+ return true;
245
+ }
246
+ const filePath = settingsPathForScope(cwd, scope);
247
+ const settings = readFullSettings(filePath);
248
+ const servers = normalizeServers(settings.mcpServers);
249
+ if (!(name in servers))
250
+ return false;
251
+ delete servers[name];
252
+ settings.mcpServers = servers;
253
+ writeFullSettings(filePath, settings);
254
+ return true;
255
+ }
256
+ /** Find which scope a server name lives in (highest-precedence scope wins).
257
+ * Returns undefined if the name isn't configured anywhere. */
258
+ export function findServerScope(cwd, name) {
259
+ const { servers } = loadMcpConfig(cwd);
260
+ return servers[name]?.scope;
261
+ }
262
+ /** Remove a server from whichever scope it's configured in. Returns the scope
263
+ * it was removed from, or undefined if not found. */
264
+ export function removeMcpServerAnyScope(cwd, name) {
265
+ const scope = findServerScope(cwd, name);
266
+ if (!scope)
267
+ return undefined;
268
+ removeMcpServer(cwd, name, scope);
269
+ return scope;
270
+ }