@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.
- package/README.md +351 -8
- package/dist/commands/mcp.js +266 -0
- package/dist/config/settings.js +27 -0
- package/dist/core/agent.js +28 -7
- package/dist/headless.js +18 -0
- package/dist/index.js +9 -0
- 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 +205 -50
- 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
package/dist/config/settings.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/core/agent.js
CHANGED
|
@@ -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.
|
|
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 (
|
|
357
|
-
|
|
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.
|
|
376
|
+
await this.mcp?.stop();
|
|
360
377
|
}
|
|
361
378
|
catch {
|
|
362
|
-
//
|
|
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,
|
package/dist/mcp/auth.js
ADDED
|
@@ -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
|
+
}
|