@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.
- package/README.md +351 -8
- package/dist/commands/mcp.js +266 -0
- package/dist/config/settings.js +27 -0
- package/dist/core/agent.js +34 -7
- package/dist/headless.js +20 -1
- package/dist/index.js +45 -2
- package/dist/mcp/auth.js +289 -0
- package/dist/mcp/client.js +132 -0
- package/dist/mcp/config.js +270 -0
- package/dist/mcp/manager.js +277 -0
- package/dist/mcp/types.js +8 -0
- package/dist/memory/loader.js +167 -0
- package/dist/memory/types.js +1 -0
- package/dist/prompts/system.js +26 -7
- package/dist/skills/loader.js +132 -0
- package/dist/skills/types.js +1 -0
- package/dist/tools/executors/skills.js +28 -0
- package/dist/tools/index.js +22 -3
- package/dist/tools/schemas/index.js +6 -3
- package/dist/tools/schemas/use_skill.js +1 -1
- package/dist/ui/App.js +214 -58
- package/dist/ui/components/McpApprovalPrompt.js +61 -0
- package/dist/ui/components/McpAuthScreen.js +55 -0
- package/dist/ui/components/McpPicker.js +262 -0
- package/dist/ui/components/StatusBar.js +28 -9
- package/dist/utils/clipboard.js +45 -0
- package/package.json +2 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { callMcpTool, connectMcpServer } from "./client.js";
|
|
2
|
+
import { configPathForScope, loadMcpConfig } from "./config.js";
|
|
3
|
+
import { hasStoredAuth, isOAuthConfig } from "./auth.js";
|
|
4
|
+
/**
|
|
5
|
+
* Owns the lifecycle of all MCP server connections for a session.
|
|
6
|
+
*
|
|
7
|
+
* On `start()`, it loads the merged config, filters by the enabled/disabled
|
|
8
|
+
* lists, connects to each enabled server in parallel, and exposes the union of
|
|
9
|
+
* their tools as OpenAI-compatible tool definitions. The agent calls
|
|
10
|
+
* `callTool()` for any `mcp__*` tool name; unknown names return an error.
|
|
11
|
+
*/
|
|
12
|
+
export class McpManager {
|
|
13
|
+
cwd;
|
|
14
|
+
connections = new Map();
|
|
15
|
+
configs = new Map();
|
|
16
|
+
states = new Map();
|
|
17
|
+
/** server names the user has explicitly disabled (persisted in settings). */
|
|
18
|
+
disabled;
|
|
19
|
+
/** server names the user has explicitly enabled (for unapproved project servers). */
|
|
20
|
+
enabled;
|
|
21
|
+
/** project-scope server names that require user approval before connecting. */
|
|
22
|
+
pendingApproval = new Set();
|
|
23
|
+
started = false;
|
|
24
|
+
constructor(cwd, disabled, enabled) {
|
|
25
|
+
this.cwd = cwd;
|
|
26
|
+
this.disabled = new Set(disabled);
|
|
27
|
+
this.enabled = new Set(enabled);
|
|
28
|
+
}
|
|
29
|
+
/** Load config and connect to all approved, non-disabled servers. */
|
|
30
|
+
async start() {
|
|
31
|
+
if (this.started)
|
|
32
|
+
return this.snapshot();
|
|
33
|
+
this.started = true;
|
|
34
|
+
const { servers } = loadMcpConfig(this.cwd);
|
|
35
|
+
this.configs.clear();
|
|
36
|
+
this.states.clear();
|
|
37
|
+
for (const [name, cfg] of Object.entries(servers)) {
|
|
38
|
+
this.configs.set(name, cfg);
|
|
39
|
+
this.states.set(name, {
|
|
40
|
+
name,
|
|
41
|
+
scope: cfg.scope,
|
|
42
|
+
status: "disabled",
|
|
43
|
+
toolCount: 0,
|
|
44
|
+
disabled: this.disabled.has(name),
|
|
45
|
+
detail: undefined,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
// Project-scope servers need explicit approval (they ship in the repo).
|
|
49
|
+
// User/local-scope servers are trusted (the user wrote them).
|
|
50
|
+
const toConnect = [];
|
|
51
|
+
for (const [name, cfg] of this.configs) {
|
|
52
|
+
if (this.disabled.has(name))
|
|
53
|
+
continue;
|
|
54
|
+
if (cfg.scope === "project" && !this.enabled.has(name)) {
|
|
55
|
+
this.pendingApproval.add(name);
|
|
56
|
+
this.states.get(name).status = "disabled";
|
|
57
|
+
this.states.get(name).detail = "awaiting approval";
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
toConnect.push(name);
|
|
61
|
+
}
|
|
62
|
+
// At startup, skip the OAuth browser flow for servers with no stored
|
|
63
|
+
// tokens — mark them needs-auth so the user can auth explicitly from /mcp.
|
|
64
|
+
await Promise.all(toConnect.map((name) => this.connectOne(name, { skipUnauthOAuth: true })));
|
|
65
|
+
return this.snapshot();
|
|
66
|
+
}
|
|
67
|
+
/** Connect (or reconnect) a single server by name.
|
|
68
|
+
* When `skipUnauthOAuth` is set, OAuth servers with no stored tokens are
|
|
69
|
+
* marked needs-auth instead of opening a browser (used at startup).
|
|
70
|
+
* When `authIntercept` is provided, the caller controls the OAuth UI
|
|
71
|
+
* (surfacing the auth URL, providing the code via callback or paste).
|
|
72
|
+
* When `forceOAuth` is set, an http/sse server without an explicit `oauth`
|
|
73
|
+
* config is treated as `oauth: true` (auto-detected OAuth — used when the
|
|
74
|
+
* user triggers auth after a 401). */
|
|
75
|
+
async connectOne(name, options) {
|
|
76
|
+
const cfg = this.configs.get(name);
|
|
77
|
+
if (!cfg)
|
|
78
|
+
return;
|
|
79
|
+
const state = this.states.get(name);
|
|
80
|
+
const isRemote = cfg.type === "http" || cfg.type === "sse";
|
|
81
|
+
// At startup, don't open a browser for OAuth servers that haven't been
|
|
82
|
+
// authenticated yet — surface them as needs-auth so the user can trigger
|
|
83
|
+
// auth explicitly from the /mcp picker. This applies to both explicitly
|
|
84
|
+
// configured OAuth servers and any remote server (which may require OAuth
|
|
85
|
+
// we don't know about yet — we'll find out on the first connect attempt).
|
|
86
|
+
if (options?.skipUnauthOAuth && isOAuthConfig(cfg) && !hasStoredAuth(name)) {
|
|
87
|
+
state.status = "needs-auth";
|
|
88
|
+
state.detail = "not authenticated — press a to authenticate";
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
state.status = "connecting";
|
|
92
|
+
state.detail = undefined;
|
|
93
|
+
try {
|
|
94
|
+
const { scope: _scope, ...serverConfig } = cfg;
|
|
95
|
+
// Auto-detect: if forceOAuth is set and this is a remote server without
|
|
96
|
+
// explicit oauth config, inject oauth: true so the auth transport is used.
|
|
97
|
+
if (options?.forceOAuth && isRemote && !isOAuthConfig(cfg)) {
|
|
98
|
+
;
|
|
99
|
+
serverConfig.oauth = true;
|
|
100
|
+
}
|
|
101
|
+
const connection = await connectMcpServer(name, serverConfig, options?.authIntercept);
|
|
102
|
+
this.connections.set(name, connection);
|
|
103
|
+
state.status = "connected";
|
|
104
|
+
state.toolCount = connection.tools.length;
|
|
105
|
+
state.detail = `${connection.tools.length} tool${connection.tools.length === 1 ? "" : "s"}`;
|
|
106
|
+
this.pendingApproval.delete(name);
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
const msg = error.message;
|
|
110
|
+
// Any remote server that returns 401/unauthorized/invalid_token needs
|
|
111
|
+
// auth — surface as needs-auth regardless of whether `oauth` was
|
|
112
|
+
// explicitly configured (auto-detection, like Claude Code).
|
|
113
|
+
if (isRemote && /unauthorized|auth|401|oauth|invalid_token/i.test(msg)) {
|
|
114
|
+
state.status = "needs-auth";
|
|
115
|
+
state.detail = "authentication required — press a to authenticate";
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
state.status = "failed";
|
|
119
|
+
state.detail = msg;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/** Disconnect a single server (keeps config; marks it disabled). */
|
|
124
|
+
async disconnectOne(name) {
|
|
125
|
+
const connection = this.connections.get(name);
|
|
126
|
+
if (connection) {
|
|
127
|
+
await connection.close();
|
|
128
|
+
this.connections.delete(name);
|
|
129
|
+
}
|
|
130
|
+
const state = this.states.get(name);
|
|
131
|
+
if (state) {
|
|
132
|
+
state.status = "disabled";
|
|
133
|
+
state.toolCount = 0;
|
|
134
|
+
state.detail = undefined;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/** Enable a project server (approving it) and connect. Uses
|
|
138
|
+
* skipUnauthOAuth so enabling doesn't open a browser — the user auths
|
|
139
|
+
* explicitly from /mcp. */
|
|
140
|
+
async enableServer(name) {
|
|
141
|
+
this.enabled.add(name);
|
|
142
|
+
this.disabled.delete(name);
|
|
143
|
+
const state = this.states.get(name);
|
|
144
|
+
if (state)
|
|
145
|
+
state.disabled = false;
|
|
146
|
+
await this.connectOne(name, { skipUnauthOAuth: true });
|
|
147
|
+
}
|
|
148
|
+
/** Disable a server and disconnect it. */
|
|
149
|
+
async disableServer(name) {
|
|
150
|
+
this.disabled.add(name);
|
|
151
|
+
this.enabled.delete(name);
|
|
152
|
+
const state = this.states.get(name);
|
|
153
|
+
if (state)
|
|
154
|
+
state.disabled = true;
|
|
155
|
+
await this.disconnectOne(name);
|
|
156
|
+
}
|
|
157
|
+
/** Re-authenticate a server in the needs-auth state: disconnect, clear
|
|
158
|
+
* persisted OAuth tokens, then reconnect with `forceOAuth` so even servers
|
|
159
|
+
* without an explicit `oauth` config use the OAuth flow (auto-detect).
|
|
160
|
+
* The `authIntercept` lets the TUI surface the auth URL and provide the
|
|
161
|
+
* code via callback or paste. */
|
|
162
|
+
async reauthServer(name, authIntercept) {
|
|
163
|
+
await this.disconnectOne(name);
|
|
164
|
+
try {
|
|
165
|
+
const fs = await import("node:fs");
|
|
166
|
+
const path = await import("node:path");
|
|
167
|
+
const { getConfigDir } = await import("../config/settings.js");
|
|
168
|
+
fs.unlinkSync(path.join(getConfigDir(), "mcp-auth", `${name}.json`));
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
// best-effort; no stored tokens to clear
|
|
172
|
+
}
|
|
173
|
+
this.disabled.delete(name);
|
|
174
|
+
await this.connectOne(name, { authIntercept, forceOAuth: true });
|
|
175
|
+
}
|
|
176
|
+
/** Close every connection. Call on session end / exit. */
|
|
177
|
+
async stop() {
|
|
178
|
+
await Promise.all([...this.connections.values()].map((c) => c.close().catch(() => { })));
|
|
179
|
+
this.connections.clear();
|
|
180
|
+
this.started = false;
|
|
181
|
+
}
|
|
182
|
+
/** All tools from all connected servers, as OpenAI tool definitions. */
|
|
183
|
+
getTools() {
|
|
184
|
+
const tools = [];
|
|
185
|
+
for (const connection of this.connections.values()) {
|
|
186
|
+
for (const tool of connection.tools) {
|
|
187
|
+
tools.push({
|
|
188
|
+
type: "function",
|
|
189
|
+
function: {
|
|
190
|
+
name: tool.name,
|
|
191
|
+
description: tool.description ?? `MCP tool ${tool.originalName} from server ${tool.server}`,
|
|
192
|
+
parameters: tool.inputSchema ?? {
|
|
193
|
+
type: "object",
|
|
194
|
+
properties: {},
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return tools;
|
|
201
|
+
}
|
|
202
|
+
/** Route an `mcp__<server>__<tool>` call to the right connection. */
|
|
203
|
+
async callTool(toolName, args) {
|
|
204
|
+
const match = /^mcp__([^_]+)__(.+)$/.exec(toolName);
|
|
205
|
+
if (!match) {
|
|
206
|
+
return { text: `Invalid MCP tool name: ${toolName}`, isError: true };
|
|
207
|
+
}
|
|
208
|
+
const [, server, originalName] = match;
|
|
209
|
+
const connection = this.connections.get(server);
|
|
210
|
+
if (!connection) {
|
|
211
|
+
return { text: `MCP server "${server}" is not connected.`, isError: true };
|
|
212
|
+
}
|
|
213
|
+
try {
|
|
214
|
+
return await callMcpTool(connection, originalName, args);
|
|
215
|
+
}
|
|
216
|
+
catch (error) {
|
|
217
|
+
return { text: `MCP tool ${toolName} failed: ${error.message}`, isError: true };
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
/** True if `toolName` is an MCP tool managed by this manager. */
|
|
221
|
+
hasTool(toolName) {
|
|
222
|
+
return /^mcp__[^_]+__/.test(toolName);
|
|
223
|
+
}
|
|
224
|
+
/** Current snapshot for the TUI / status display. */
|
|
225
|
+
snapshot() {
|
|
226
|
+
return {
|
|
227
|
+
servers: [...this.states.values()],
|
|
228
|
+
tools: [...this.connections.values()].flatMap((c) => c.tools),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
/** Project-scope server names awaiting the user's approval. */
|
|
232
|
+
getPendingApproval() {
|
|
233
|
+
return [...this.pendingApproval];
|
|
234
|
+
}
|
|
235
|
+
/** Persisted enabled list (for saving to settings). */
|
|
236
|
+
getEnabled() {
|
|
237
|
+
return [...this.enabled];
|
|
238
|
+
}
|
|
239
|
+
/** Persisted disabled list (for saving to settings). */
|
|
240
|
+
getDisabled() {
|
|
241
|
+
return [...this.disabled];
|
|
242
|
+
}
|
|
243
|
+
/** All configured server names. */
|
|
244
|
+
getServerNames() {
|
|
245
|
+
return [...this.configs.keys()];
|
|
246
|
+
}
|
|
247
|
+
/** A config by name (for the TUI detail view). */
|
|
248
|
+
getConfig(name) {
|
|
249
|
+
return this.configs.get(name);
|
|
250
|
+
}
|
|
251
|
+
/** The file path where a server's config lives (for the TUI detail view). */
|
|
252
|
+
getConfigPath(name) {
|
|
253
|
+
const cfg = this.configs.get(name);
|
|
254
|
+
if (!cfg)
|
|
255
|
+
return undefined;
|
|
256
|
+
return configPathForScope(this.cwd, cfg.scope);
|
|
257
|
+
}
|
|
258
|
+
/** True if a server uses OAuth (explicitly configured or auto-detected from
|
|
259
|
+
* a 401/needs-auth state). Remote servers in needs-auth state are treated
|
|
260
|
+
* as OAuth so the detail panel shows "not authenticated". */
|
|
261
|
+
isOAuthServer(name) {
|
|
262
|
+
const cfg = this.configs.get(name);
|
|
263
|
+
if (!cfg)
|
|
264
|
+
return false;
|
|
265
|
+
if (isOAuthConfig(cfg))
|
|
266
|
+
return true;
|
|
267
|
+
// Auto-detect: a remote server in needs-auth state requires OAuth.
|
|
268
|
+
const state = this.states.get(name);
|
|
269
|
+
if (state?.status === "needs-auth" && (cfg.type === "http" || cfg.type === "sse"))
|
|
270
|
+
return true;
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
/** True if a server has persisted OAuth tokens (already authenticated). */
|
|
274
|
+
isAuthenticated(name) {
|
|
275
|
+
return hasStoredAuth(name);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP server configuration and connection types.
|
|
3
|
+
*
|
|
4
|
+
* These are OrbCode's own types (not re-exported from the SDK) so the config
|
|
5
|
+
* layer stays decoupled from the SDK's internal schema shape. The connection
|
|
6
|
+
* manager converts these into SDK transports/clients.
|
|
7
|
+
*/
|
|
8
|
+
export {};
|