@semalt-ai/code 1.8.4 → 1.19.0
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/.claude/settings.local.json +8 -1
- package/.github/workflows/ci.yml +69 -0
- package/CLAUDE.md +1588 -27
- package/README.md +147 -3
- package/TECHNICAL_DEBT.md +66 -0
- package/examples/embed.js +74 -0
- package/index.js +259 -11
- package/lib/agent.js +935 -181
- package/lib/api.js +308 -55
- package/lib/args.js +96 -2
- package/lib/audit.js +23 -1
- package/lib/background.js +584 -0
- package/lib/checkpoints.js +757 -0
- package/lib/commands/auth.js +94 -0
- package/lib/commands/chat-session.js +306 -0
- package/lib/commands/chat-slash.js +399 -0
- package/lib/commands/chat-turn.js +446 -0
- package/lib/commands/chat.js +403 -0
- package/lib/commands/custom.js +157 -0
- package/lib/commands/history-utils.js +66 -0
- package/lib/commands/index.js +268 -0
- package/lib/commands/mcp.js +113 -0
- package/lib/commands/oneshot.js +193 -0
- package/lib/commands/registry.js +269 -0
- package/lib/commands/tasks.js +89 -0
- package/lib/compact.js +87 -0
- package/lib/config.js +346 -11
- package/lib/constants.js +372 -3
- package/lib/debug.js +106 -0
- package/lib/deny.js +199 -0
- package/lib/doctor.js +160 -0
- package/lib/headless.js +167 -0
- package/lib/hooks.js +286 -0
- package/lib/images.js +264 -0
- package/lib/internals.js +49 -0
- package/lib/mcp/boundary.js +131 -0
- package/lib/mcp/client.js +270 -0
- package/lib/mcp/oauth.js +134 -0
- package/lib/memory.js +209 -0
- package/lib/metrics.js +37 -2
- package/lib/payload.js +54 -0
- package/lib/permission-rules.js +401 -0
- package/lib/permissions.js +100 -10
- package/lib/pricing.js +67 -0
- package/lib/proc.js +158 -0
- package/lib/prompts.js +88 -8
- package/lib/sandbox.js +568 -0
- package/lib/sdk.js +328 -0
- package/lib/secrets.js +211 -0
- package/lib/skills.js +223 -0
- package/lib/subagents.js +516 -0
- package/lib/tool_registry.js +2558 -0
- package/lib/tool_specs.js +236 -9
- package/lib/tools.js +370 -944
- package/lib/ui/chat-history.js +19 -1
- package/lib/ui/format.js +101 -6
- package/lib/ui/input-field.js +16 -7
- package/lib/ui/status-bar.js +79 -11
- package/lib/ui/terminal.js +10 -4
- package/lib/ui/theme.js +1 -0
- package/lib/ui/web-activity.js +218 -0
- package/lib/ui/writer.js +7 -9
- package/lib/verify.js +229 -0
- package/lib/web-extract.js +213 -0
- package/lib/web-summarize.js +68 -0
- package/package.json +19 -4
- package/scripts/lint.js +57 -0
- package/test/agent-loop.test.js +389 -0
- package/test/background.test.js +414 -0
- package/test/chat.test.js +114 -0
- package/test/checkpoints-agent.test.js +181 -0
- package/test/checkpoints.test.js +650 -0
- package/test/command-registry.test.js +160 -0
- package/test/compact.test.js +116 -0
- package/test/completion-lazy.test.js +52 -0
- package/test/config-merge.test.js +324 -0
- package/test/config-quarantine.test.js +128 -0
- package/test/config-write-guard-allow-anywhere.test.js +56 -0
- package/test/config-write-guard-skip.test.js +46 -0
- package/test/config-write-guard.test.js +153 -0
- package/test/context-split.test.js +215 -0
- package/test/cost-doctor.test.js +142 -0
- package/test/custom-commands-chat.test.js +106 -0
- package/test/custom-commands.test.js +230 -0
- package/test/deny-windows.test.js +120 -0
- package/test/deny.test.js +83 -0
- package/test/download-allow-anywhere.test.js +66 -0
- package/test/download-confine.test.js +153 -0
- package/test/executors.test.js +362 -0
- package/test/extract-tool-calls.test.js +315 -0
- package/test/fetch-url-validation.test.js +219 -0
- package/test/fixtures/tool-calls.js +57 -0
- package/test/fixtures/web-page.js +91 -0
- package/test/git-tools.test.js +384 -0
- package/test/grep-glob-serialize.test.js +242 -0
- package/test/grep-glob.test.js +268 -0
- package/test/harness/README.md +57 -0
- package/test/harness/chat-harness.js +142 -0
- package/test/harness/memwarn-headless-child.js +65 -0
- package/test/harness/mock-llm.js +120 -0
- package/test/harness/mock-mcp-server.js +142 -0
- package/test/harness/sse-server.js +69 -0
- package/test/headless.test.js +203 -0
- package/test/history-utils.test.js +88 -0
- package/test/hooks-agent.test.js +238 -0
- package/test/hooks-verify-sandbox.test.js +232 -0
- package/test/hooks.test.js +216 -0
- package/test/http-get-user-agent.test.js +142 -0
- package/test/images-api.test.js +208 -0
- package/test/images.test.js +238 -0
- package/test/max-iterations.test.js +216 -0
- package/test/mcp-boundary.test.js +57 -0
- package/test/mcp-client.test.js +267 -0
- package/test/mcp-oauth.test.js +86 -0
- package/test/memory-truncation-warning.test.js +222 -0
- package/test/memory.test.js +198 -0
- package/test/native-dispatch.test.js +356 -0
- package/test/output-chokepoint.test.js +188 -0
- package/test/path-guards.test.js +134 -0
- package/test/payload.test.js +99 -0
- package/test/permission-rules-agent.test.js +210 -0
- package/test/permission-rules.test.js +297 -0
- package/test/permissions.test.js +163 -0
- package/test/plan-mode.test.js +167 -0
- package/test/read-paginate.test.js +275 -0
- package/test/readonly-tools.test.js +177 -0
- package/test/result-cap.test.js +233 -0
- package/test/sandbox-agent.test.js +147 -0
- package/test/sandbox-integration.test.js +216 -0
- package/test/sandbox.test.js +408 -0
- package/test/sdk.test.js +234 -0
- package/test/shell-output-cap.test.js +181 -0
- package/test/skills-chat.test.js +110 -0
- package/test/skills.test.js +295 -0
- package/test/smoke.test.js +68 -0
- package/test/status-bar-pause.test.js +164 -0
- package/test/stream-parser.test.js +147 -0
- package/test/subagents-agent.test.js +178 -0
- package/test/subagents.test.js +222 -0
- package/test/tool-registry.test.js +85 -0
- package/test/trim-budget.test.js +101 -0
- package/test/verify-agent.test.js +317 -0
- package/test/verify.test.js +141 -0
- package/test/web-activity-ordering.test.js +194 -0
- package/test/web-activity.test.js +207 -0
- package/test/web-data-extraction-guidance.test.js +71 -0
- package/test/web-extract.test.js +185 -0
- package/test/web-fetch-agent.test.js +291 -0
- package/test/web-fetch-mode.test.js +193 -0
- package/test/web-search.test.js +380 -0
- package/lib/commands.js +0 -1288
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// MCP boundary module (Task 3.2)
|
|
4
|
+
// -----------------------------------------------------------------------------
|
|
5
|
+
// The official MCP SDK (`@modelcontextprotocol/sdk`) is the project's first — and
|
|
6
|
+
// so far only — runtime dependency. It is **ESM-only** (`"type": "module"`, no
|
|
7
|
+
// CommonJS entry points), but this project is CommonJS: a CJS module cannot
|
|
8
|
+
// `require()` an ESM-only package.
|
|
9
|
+
//
|
|
10
|
+
// This module is the SINGLE place that bridges the gap. It loads the SDK via
|
|
11
|
+
// dynamic `import()` (the one mechanism CJS has for pulling in ESM) and re-exposes
|
|
12
|
+
// a small, CommonJS-friendly **async** surface. Every other file in the codebase
|
|
13
|
+
// stays plain CommonJS and talks to MCP through this boundary — no other module
|
|
14
|
+
// imports the SDK directly. That keeps the ESM/CJS friction contained to one file
|
|
15
|
+
// and means the rest of the project never has to change its module system.
|
|
16
|
+
//
|
|
17
|
+
// The dynamic import is memoized: the SDK's ESM graph is evaluated at most once
|
|
18
|
+
// per process, on first use (lazy — importing this module costs nothing until a
|
|
19
|
+
// boundary function is actually called). Task 3.3 builds the MCP client on top of
|
|
20
|
+
// the helpers here.
|
|
21
|
+
|
|
22
|
+
const { PACKAGE_JSON } = require('../constants');
|
|
23
|
+
|
|
24
|
+
// The SDK subpaths we consume. The SDK exposes deep subpath exports
|
|
25
|
+
// (`./client/index.js`, `./client/stdio.js`, …) rather than a single barrel, so
|
|
26
|
+
// we import exactly what we use.
|
|
27
|
+
const CLIENT_SUBPATH = '@modelcontextprotocol/sdk/client/index.js';
|
|
28
|
+
const STDIO_SUBPATH = '@modelcontextprotocol/sdk/client/stdio.js';
|
|
29
|
+
const HTTP_SUBPATH = '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
|
30
|
+
const SSE_SUBPATH = '@modelcontextprotocol/sdk/client/sse.js';
|
|
31
|
+
|
|
32
|
+
let _sdkPromise = null;
|
|
33
|
+
|
|
34
|
+
// Lazily load + memoize the SDK's client surface. `import()` is the ONLY bridge
|
|
35
|
+
// from this CommonJS module to the ESM-only package — do not try to `require()`
|
|
36
|
+
// the SDK anywhere. Returns just the named exports the rest of the code needs.
|
|
37
|
+
async function loadSdk() {
|
|
38
|
+
if (!_sdkPromise) {
|
|
39
|
+
_sdkPromise = (async () => {
|
|
40
|
+
const [clientMod, stdioMod] = await Promise.all([
|
|
41
|
+
import(CLIENT_SUBPATH),
|
|
42
|
+
import(STDIO_SUBPATH),
|
|
43
|
+
]);
|
|
44
|
+
return {
|
|
45
|
+
Client: clientMod.Client,
|
|
46
|
+
StdioClientTransport: stdioMod.StdioClientTransport,
|
|
47
|
+
};
|
|
48
|
+
})();
|
|
49
|
+
// If the import rejects (e.g. the dependency is not installed), clear the
|
|
50
|
+
// cache so a later call can retry rather than re-throwing a stale rejection.
|
|
51
|
+
_sdkPromise.catch(() => { _sdkPromise = null; });
|
|
52
|
+
}
|
|
53
|
+
return _sdkPromise;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Whether the SDK is resolvable in this environment (installed in node_modules).
|
|
57
|
+
// Synchronous and side-effect-free: used by the smoke test to skip gracefully
|
|
58
|
+
// when the dependency could not be installed (e.g. an offline CI runner) instead
|
|
59
|
+
// of failing the suite.
|
|
60
|
+
function isSdkAvailable() {
|
|
61
|
+
try {
|
|
62
|
+
require.resolve(CLIENT_SUBPATH);
|
|
63
|
+
return true;
|
|
64
|
+
} catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Default identity advertised to MCP servers during the connect handshake.
|
|
70
|
+
const DEFAULT_CLIENT_INFO = Object.freeze({
|
|
71
|
+
name: PACKAGE_JSON.name,
|
|
72
|
+
version: PACKAGE_JSON.version,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// Instantiate an MCP `Client`. Does NOT connect — Task 3.3 owns transport wiring
|
|
76
|
+
// and the `connect()` handshake. `clientInfo` defaults to this CLI's identity;
|
|
77
|
+
// `options` defaults to declaring no client capabilities.
|
|
78
|
+
async function createClient(clientInfo, options) {
|
|
79
|
+
const { Client } = await loadSdk();
|
|
80
|
+
return new Client(
|
|
81
|
+
clientInfo || { ...DEFAULT_CLIENT_INFO },
|
|
82
|
+
options || { capabilities: {} },
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Construct a stdio transport for launching a local MCP server subprocess.
|
|
87
|
+
// `params` is the SDK's `StdioServerParameters` ({ command, args, env, … }).
|
|
88
|
+
// When the caller supplies `env`, it is MERGED over the SDK's default safe
|
|
89
|
+
// environment (getDefaultEnvironment) rather than replacing it — otherwise the
|
|
90
|
+
// child would lose PATH/HOME and fail to launch most real servers.
|
|
91
|
+
async function createStdioTransport(params) {
|
|
92
|
+
const stdioMod = await import(STDIO_SUBPATH);
|
|
93
|
+
const { StdioClientTransport, getDefaultEnvironment } = stdioMod;
|
|
94
|
+
const merged = { ...params };
|
|
95
|
+
if (params && params.env && typeof getDefaultEnvironment === 'function') {
|
|
96
|
+
merged.env = { ...getDefaultEnvironment(), ...params.env };
|
|
97
|
+
}
|
|
98
|
+
return new StdioClientTransport(merged);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Construct a Streamable-HTTP transport for a remote MCP server. `url` is a URL
|
|
102
|
+
// (or string); `opts` is the SDK's `StreamableHTTPClientTransportOptions`
|
|
103
|
+
// ({ authProvider, requestInit, … }). OAuth is wired through `opts.authProvider`.
|
|
104
|
+
async function createStreamableHttpTransport(url, opts) {
|
|
105
|
+
const mod = await import(HTTP_SUBPATH);
|
|
106
|
+
return new mod.StreamableHTTPClientTransport(url instanceof URL ? url : new URL(url), opts);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Construct a legacy HTTP+SSE transport for a remote MCP server. Same shape as
|
|
110
|
+
// the streamable-HTTP transport; used for servers that only speak the older
|
|
111
|
+
// SSE protocol.
|
|
112
|
+
async function createSseTransport(url, opts) {
|
|
113
|
+
const mod = await import(SSE_SUBPATH);
|
|
114
|
+
return new mod.SSEClientTransport(url instanceof URL ? url : new URL(url), opts);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Test seam: drop the memoized import so a fresh load can be exercised.
|
|
118
|
+
function _reset() {
|
|
119
|
+
_sdkPromise = null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
module.exports = {
|
|
123
|
+
loadSdk,
|
|
124
|
+
isSdkAvailable,
|
|
125
|
+
createClient,
|
|
126
|
+
createStdioTransport,
|
|
127
|
+
createStreamableHttpTransport,
|
|
128
|
+
createSseTransport,
|
|
129
|
+
DEFAULT_CLIENT_INFO,
|
|
130
|
+
_reset,
|
|
131
|
+
};
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// MCP client manager (Task 3.3).
|
|
4
|
+
// ----------------------------------------------------------------------------
|
|
5
|
+
// Connects to the MCP servers configured under `config.mcp.servers`, discovers
|
|
6
|
+
// each server's tools, and registers them into the runtime tool registry
|
|
7
|
+
// (lib/tool_registry.js dynamic API) under the namespace `mcp__<server>__<tool>`
|
|
8
|
+
// so they dispatch through the SAME agent loop as built-ins.
|
|
9
|
+
//
|
|
10
|
+
// Security posture (required by the task):
|
|
11
|
+
// * MCP tool RESULTS are untrusted external content — the execute() here
|
|
12
|
+
// returns `{ mcp:true, content, isError }`, and the agent loop wraps that
|
|
13
|
+
// payload in the UNTRUSTED_EXTERNAL_CONTENT delimiter (lib/agent.js),
|
|
14
|
+
// identical to http_get.
|
|
15
|
+
// * MCP tools are arbitrary/external, so they REQUIRE APPROVAL by default —
|
|
16
|
+
// never auto-allowed by the `--allow-*` tiers. Per-server/per-tool opt-in
|
|
17
|
+
// comes from config (`allow: [...]` or `allowAll: true`).
|
|
18
|
+
//
|
|
19
|
+
// Robustness: a server that fails to launch/connect degrades gracefully (the
|
|
20
|
+
// failure is recorded in status, a warning is logged, and the CLI continues) —
|
|
21
|
+
// it never crashes the process. The SDK itself is reached only through
|
|
22
|
+
// lib/mcp/boundary.js (the single CJS↔ESM bridge); this module never imports it.
|
|
23
|
+
|
|
24
|
+
const realBoundary = require('./boundary');
|
|
25
|
+
const {
|
|
26
|
+
registerDynamicTool, unregisterDynamicTool,
|
|
27
|
+
} = require('../tool_registry');
|
|
28
|
+
const { logToolCall } = require('../audit');
|
|
29
|
+
const { createKeychainOAuthProvider } = require('./oauth');
|
|
30
|
+
|
|
31
|
+
const realRegistry = { registerDynamicTool, unregisterDynamicTool };
|
|
32
|
+
|
|
33
|
+
const DEFAULT_CONNECT_TIMEOUT_MS = 15000;
|
|
34
|
+
|
|
35
|
+
// `mcp__<server>__<tool>` with non-identifier chars folded to `_` so the result
|
|
36
|
+
// is a valid native function name (the LLM echoes it back verbatim in tool_calls).
|
|
37
|
+
function mcpToolName(server, tool) {
|
|
38
|
+
const s = String(server).replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
39
|
+
const t = String(tool).replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
40
|
+
return `mcp__${s}__${t}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Flatten an MCP CallToolResult's content blocks into a single text string.
|
|
44
|
+
// Text blocks pass through; non-text blocks (image/resource/…) are summarized
|
|
45
|
+
// as a JSON line so nothing is silently dropped from the model's view.
|
|
46
|
+
function mcpResultToText(result) {
|
|
47
|
+
if (!result) return '';
|
|
48
|
+
const blocks = Array.isArray(result.content) ? result.content : [];
|
|
49
|
+
const parts = [];
|
|
50
|
+
for (const b of blocks) {
|
|
51
|
+
if (b && b.type === 'text' && typeof b.text === 'string') parts.push(b.text);
|
|
52
|
+
else if (b && typeof b === 'object') parts.push(`[${b.type || 'content'}] ${JSON.stringify(b)}`);
|
|
53
|
+
}
|
|
54
|
+
if (!parts.length && result.structuredContent !== undefined) {
|
|
55
|
+
parts.push(JSON.stringify(result.structuredContent));
|
|
56
|
+
}
|
|
57
|
+
return parts.join('\n');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Decide whether a discovered tool is pre-approved (opt-in) for this server.
|
|
61
|
+
// Matches either the bare tool name or its full namespaced form in `allow`.
|
|
62
|
+
function isToolAllowed(spec, toolName, namespacedName) {
|
|
63
|
+
if (!spec) return false;
|
|
64
|
+
if (spec.allowAll === true) return true;
|
|
65
|
+
const allow = Array.isArray(spec.allow) ? spec.allow : [];
|
|
66
|
+
return allow.includes(toolName) || allow.includes(namespacedName);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Build the dynamic tool-registry entry for one discovered MCP tool. Shape is
|
|
70
|
+
// identical to a static entry so the agent loop dispatches it the same way.
|
|
71
|
+
function buildMcpToolEntry({ server, spec, tool, client }) {
|
|
72
|
+
const name = mcpToolName(server, tool.name);
|
|
73
|
+
const allowed = isToolAllowed(spec, tool.name, name);
|
|
74
|
+
const description = `[MCP:${server}] ${tool.description || tool.name}`;
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
tool: name,
|
|
78
|
+
mcp: true,
|
|
79
|
+
server,
|
|
80
|
+
origName: tool.name,
|
|
81
|
+
spec: {
|
|
82
|
+
description,
|
|
83
|
+
parameters: tool.inputSchema && typeof tool.inputSchema === 'object'
|
|
84
|
+
? tool.inputSchema
|
|
85
|
+
: { type: 'object', properties: {} },
|
|
86
|
+
},
|
|
87
|
+
// Native function-calling path: the model emits { name, arguments } → the
|
|
88
|
+
// whole arguments object becomes the single positional arg of the tuple.
|
|
89
|
+
fromParams: (p) => [name, p || {}],
|
|
90
|
+
// XML path (non-native models): <mcp__server__tool>{json args}</mcp__server__tool>.
|
|
91
|
+
parseXml: (text) => {
|
|
92
|
+
const out = [];
|
|
93
|
+
const re = new RegExp(`<${name}\\s*>([\\s\\S]*?)<\\/${name}>`, 'g');
|
|
94
|
+
for (const m of text.matchAll(re)) {
|
|
95
|
+
const body = (m[1] || '').trim();
|
|
96
|
+
let args = {};
|
|
97
|
+
if (body) { try { args = JSON.parse(body); } catch { args = {}; } }
|
|
98
|
+
out.push([name, args]);
|
|
99
|
+
}
|
|
100
|
+
// Self-closing form with no args: <mcp__server__tool/>
|
|
101
|
+
const selfRe = new RegExp(`<${name}\\s*/>`, 'g');
|
|
102
|
+
for (const _m of text.matchAll(selfRe)) out.push([name, {}]);
|
|
103
|
+
return out;
|
|
104
|
+
},
|
|
105
|
+
// Approval gate: MCP tools require approval by DEFAULT. Opt-in via config
|
|
106
|
+
// (allow/allowAll) returns null → no gate (treated like a read-only tool).
|
|
107
|
+
permission: () => {
|
|
108
|
+
if (allowed) return null;
|
|
109
|
+
return { actionType: 'mcp', description: `MCP ${server}/${tool.name}`, tag: name };
|
|
110
|
+
},
|
|
111
|
+
execute: async (_ctx, args, options) => {
|
|
112
|
+
const params = (args && args[0]) || {};
|
|
113
|
+
const signal = (options && options.signal) || undefined;
|
|
114
|
+
try {
|
|
115
|
+
const res = await client.callTool(
|
|
116
|
+
{ name: tool.name, arguments: params },
|
|
117
|
+
undefined,
|
|
118
|
+
signal ? { signal } : undefined,
|
|
119
|
+
);
|
|
120
|
+
logToolCall(name, { server, tool: tool.name }, true, res && res.isError ? 'error' : 'ok');
|
|
121
|
+
return { mcp: true, content: mcpResultToText(res), isError: !!(res && res.isError) };
|
|
122
|
+
} catch (err) {
|
|
123
|
+
logToolCall(name, { server, tool: tool.name }, true, 'error');
|
|
124
|
+
return { error: err && err.message ? err.message : String(err) };
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function withTimeout(promise, ms, onTimeoutMessage) {
|
|
131
|
+
let timer;
|
|
132
|
+
const timeout = new Promise((_, reject) => {
|
|
133
|
+
timer = setTimeout(() => reject(new Error(onTimeoutMessage)), ms);
|
|
134
|
+
});
|
|
135
|
+
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function createMcpManager({
|
|
139
|
+
getConfig,
|
|
140
|
+
boundary = realBoundary,
|
|
141
|
+
registry = realRegistry,
|
|
142
|
+
oauthFactory = createKeychainOAuthProvider,
|
|
143
|
+
logger = null,
|
|
144
|
+
connectTimeoutMs = DEFAULT_CONNECT_TIMEOUT_MS,
|
|
145
|
+
} = {}) {
|
|
146
|
+
const _clients = new Map(); // server name → connected Client
|
|
147
|
+
const _toolNames = []; // registered dynamic tool names
|
|
148
|
+
let _status = []; // per-server status records
|
|
149
|
+
|
|
150
|
+
function warn(msg) {
|
|
151
|
+
if (typeof logger === 'function') logger(msg);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Resolve the transport for a server spec. Throws on an invalid/missing spec.
|
|
155
|
+
async function buildTransport(name, spec) {
|
|
156
|
+
const transport = (spec.transport || (spec.url ? 'http' : 'stdio')).toLowerCase();
|
|
157
|
+
if (transport === 'stdio') {
|
|
158
|
+
if (!spec.command) throw new Error(`stdio server "${name}" requires a "command"`);
|
|
159
|
+
return boundary.createStdioTransport({
|
|
160
|
+
command: spec.command,
|
|
161
|
+
args: Array.isArray(spec.args) ? spec.args : [],
|
|
162
|
+
env: spec.env && typeof spec.env === 'object' ? spec.env : undefined,
|
|
163
|
+
cwd: spec.cwd || undefined,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
if (transport === 'http' || transport === 'streamable-http' || transport === 'sse') {
|
|
167
|
+
if (!spec.url) throw new Error(`remote server "${name}" requires a "url"`);
|
|
168
|
+
const opts = {};
|
|
169
|
+
if (spec.headers && typeof spec.headers === 'object') {
|
|
170
|
+
opts.requestInit = { headers: spec.headers };
|
|
171
|
+
}
|
|
172
|
+
// OAuth: opt-in via spec.oauth (or spec.auth === 'oauth'). Tokens are
|
|
173
|
+
// persisted in the OS keychain by the provider, never in config.
|
|
174
|
+
if (spec.oauth === true || spec.auth === 'oauth') {
|
|
175
|
+
opts.authProvider = oauthFactory(name, { url: spec.url });
|
|
176
|
+
}
|
|
177
|
+
return transport === 'sse'
|
|
178
|
+
? boundary.createSseTransport(spec.url, opts)
|
|
179
|
+
: boundary.createStreamableHttpTransport(spec.url, opts);
|
|
180
|
+
}
|
|
181
|
+
throw new Error(`server "${name}" has unknown transport "${transport}"`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function connectServer(name, spec) {
|
|
185
|
+
const transportKind = (spec.transport || (spec.url ? 'http' : 'stdio')).toLowerCase();
|
|
186
|
+
const record = { name, transport: transportKind, state: 'connecting', tools: [], error: null };
|
|
187
|
+
if (spec.disabled) {
|
|
188
|
+
record.state = 'disabled';
|
|
189
|
+
return record;
|
|
190
|
+
}
|
|
191
|
+
let client = null;
|
|
192
|
+
try {
|
|
193
|
+
const transport = await buildTransport(name, spec);
|
|
194
|
+
client = await boundary.createClient();
|
|
195
|
+
await withTimeout(
|
|
196
|
+
client.connect(transport),
|
|
197
|
+
connectTimeoutMs,
|
|
198
|
+
`connect timed out after ${connectTimeoutMs}ms`,
|
|
199
|
+
);
|
|
200
|
+
const listed = await client.listTools();
|
|
201
|
+
const tools = Array.isArray(listed && listed.tools) ? listed.tools : [];
|
|
202
|
+
for (const tool of tools) {
|
|
203
|
+
if (!tool || typeof tool.name !== 'string') continue;
|
|
204
|
+
const entry = buildMcpToolEntry({ server: name, spec, tool, client });
|
|
205
|
+
registry.registerDynamicTool(entry);
|
|
206
|
+
record.tools.push(entry.tool);
|
|
207
|
+
_toolNames.push(entry.tool);
|
|
208
|
+
}
|
|
209
|
+
record.state = 'connected';
|
|
210
|
+
_clients.set(name, client);
|
|
211
|
+
} catch (err) {
|
|
212
|
+
record.state = 'failed';
|
|
213
|
+
record.error = err && err.message ? err.message : String(err);
|
|
214
|
+
warn(`MCP server "${name}" failed: ${record.error}`);
|
|
215
|
+
// Best-effort cleanup of a half-open client so a failed server leaks nothing.
|
|
216
|
+
if (client) { try { await client.close(); } catch { /* ignore */ } }
|
|
217
|
+
}
|
|
218
|
+
return record;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Connect to every configured server. Failures are isolated per-server (one
|
|
222
|
+
// bad server never blocks the others, and never throws out of here).
|
|
223
|
+
async function connectAll() {
|
|
224
|
+
const cfg = getConfig ? getConfig() : {};
|
|
225
|
+
const servers = (cfg && cfg.mcp && cfg.mcp.servers) || {};
|
|
226
|
+
const names = Object.keys(servers);
|
|
227
|
+
const records = [];
|
|
228
|
+
for (const name of names) {
|
|
229
|
+
records.push(await connectServer(name, servers[name] || {}));
|
|
230
|
+
}
|
|
231
|
+
_status = records;
|
|
232
|
+
return records;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function status() {
|
|
236
|
+
return _status.map((r) => ({ ...r, tools: r.tools.slice() }));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function registeredToolNames() {
|
|
240
|
+
return _toolNames.slice();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function shutdown() {
|
|
244
|
+
for (const name of _toolNames) {
|
|
245
|
+
try { registry.unregisterDynamicTool(name); } catch { /* ignore */ }
|
|
246
|
+
}
|
|
247
|
+
_toolNames.length = 0;
|
|
248
|
+
for (const client of _clients.values()) {
|
|
249
|
+
try { await client.close(); } catch { /* ignore */ }
|
|
250
|
+
}
|
|
251
|
+
_clients.clear();
|
|
252
|
+
_status = [];
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
connectAll,
|
|
257
|
+
connectServer,
|
|
258
|
+
status,
|
|
259
|
+
registeredToolNames,
|
|
260
|
+
shutdown,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
module.exports = {
|
|
265
|
+
createMcpManager,
|
|
266
|
+
buildMcpToolEntry,
|
|
267
|
+
mcpToolName,
|
|
268
|
+
mcpResultToText,
|
|
269
|
+
isToolAllowed,
|
|
270
|
+
};
|
package/lib/mcp/oauth.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// MCP OAuth — keychain-backed token store (Task 3.3).
|
|
4
|
+
// ----------------------------------------------------------------------------
|
|
5
|
+
// Remote MCP servers (HTTP/SSE) may require OAuth. The SDK drives the OAuth 2.1
|
|
6
|
+
// + PKCE flow through an `OAuthClientProvider` interface; this module implements
|
|
7
|
+
// that interface and persists EVERYTHING sensitive — tokens, the dynamically
|
|
8
|
+
// registered client credentials, and the PKCE code verifier — in the OS
|
|
9
|
+
// keychain, never in plaintext config. That mirrors the Phase 0 secret path
|
|
10
|
+
// (lib/secrets.js): secrets live in the keychain, config holds only references.
|
|
11
|
+
//
|
|
12
|
+
// The keychain access is injected (`store`) so it can be unit-tested with an
|
|
13
|
+
// in-memory fake; in production it defaults to the generic keychain helpers in
|
|
14
|
+
// lib/secrets.js under the service `semalt-code-mcp`, keyed per server.
|
|
15
|
+
//
|
|
16
|
+
// Records are JSON blobs stored under three accounts per server:
|
|
17
|
+
// <server>:tokens — the OAuthTokens (access/refresh/expiry)
|
|
18
|
+
// <server>:client — the registered OAuthClientInformation
|
|
19
|
+
// <server>:verifier — the in-flight PKCE code verifier
|
|
20
|
+
//
|
|
21
|
+
// `redirectToAuthorization` opens the user's browser (best-effort) and prints
|
|
22
|
+
// the URL so headless/remote sessions can complete the flow manually.
|
|
23
|
+
|
|
24
|
+
const { spawn } = require('child_process');
|
|
25
|
+
const {
|
|
26
|
+
keychainGetItem, keychainSetItem, keychainDeleteItem,
|
|
27
|
+
} = require('../secrets');
|
|
28
|
+
|
|
29
|
+
const MCP_KEYCHAIN_SERVICE = 'semalt-code-mcp';
|
|
30
|
+
|
|
31
|
+
// Default production store: the OS keychain via lib/secrets.js generic helpers.
|
|
32
|
+
function keychainStore(service = MCP_KEYCHAIN_SERVICE) {
|
|
33
|
+
return {
|
|
34
|
+
get(account) { return keychainGetItem(service, account); },
|
|
35
|
+
set(account, value) { return keychainSetItem(service, account, value, `MCP OAuth ${account}`); },
|
|
36
|
+
delete(account) { return keychainDeleteItem(service, account); },
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Best-effort browser opener — same idea the device-login flow uses. Never
|
|
41
|
+
// throws; if no opener is available the URL is just printed for manual use.
|
|
42
|
+
function _openBrowser(url) {
|
|
43
|
+
const platform = process.platform;
|
|
44
|
+
let cmd; let args;
|
|
45
|
+
if (platform === 'darwin') { cmd = 'open'; args = [url]; }
|
|
46
|
+
else if (platform === 'win32') { cmd = 'cmd'; args = ['/c', 'start', '', url]; }
|
|
47
|
+
else { cmd = 'xdg-open'; args = [url]; }
|
|
48
|
+
try {
|
|
49
|
+
const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
|
|
50
|
+
child.on('error', () => {});
|
|
51
|
+
child.unref();
|
|
52
|
+
} catch { /* ignore — URL is printed below */ }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function _parse(raw) {
|
|
56
|
+
if (!raw) return undefined;
|
|
57
|
+
try { return JSON.parse(raw); } catch { return undefined; }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Build a keychain-backed OAuthClientProvider for one server.
|
|
61
|
+
// server — config key, used to namespace keychain accounts.
|
|
62
|
+
// url — the server URL (origin used as redirect base).
|
|
63
|
+
// store — injectable { get, set, delete } (defaults to OS keychain).
|
|
64
|
+
// onRedirect — optional callback(url) instead of opening a browser (tests).
|
|
65
|
+
function createKeychainOAuthProvider(server, {
|
|
66
|
+
url = '',
|
|
67
|
+
store = keychainStore(),
|
|
68
|
+
redirectUrl = 'http://127.0.0.1:8976/callback',
|
|
69
|
+
clientName = '@semalt-ai/code',
|
|
70
|
+
onRedirect = null,
|
|
71
|
+
} = {}) {
|
|
72
|
+
const acct = (kind) => `${server}:${kind}`;
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
get redirectUrl() { return redirectUrl; },
|
|
76
|
+
|
|
77
|
+
get clientMetadata() {
|
|
78
|
+
return {
|
|
79
|
+
client_name: clientName,
|
|
80
|
+
redirect_uris: [redirectUrl],
|
|
81
|
+
grant_types: ['authorization_code', 'refresh_token'],
|
|
82
|
+
response_types: ['code'],
|
|
83
|
+
token_endpoint_auth_method: 'none',
|
|
84
|
+
};
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
clientInformation() {
|
|
88
|
+
return _parse(store.get(acct('client')));
|
|
89
|
+
},
|
|
90
|
+
saveClientInformation(info) {
|
|
91
|
+
store.set(acct('client'), JSON.stringify(info));
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
tokens() {
|
|
95
|
+
return _parse(store.get(acct('tokens')));
|
|
96
|
+
},
|
|
97
|
+
saveTokens(tokens) {
|
|
98
|
+
store.set(acct('tokens'), JSON.stringify(tokens));
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
saveCodeVerifier(verifier) {
|
|
102
|
+
store.set(acct('verifier'), String(verifier));
|
|
103
|
+
},
|
|
104
|
+
codeVerifier() {
|
|
105
|
+
const v = store.get(acct('verifier'));
|
|
106
|
+
if (!v) throw new Error('No PKCE code verifier saved for this MCP server');
|
|
107
|
+
return v;
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
redirectToAuthorization(authorizationUrl) {
|
|
111
|
+
const href = authorizationUrl instanceof URL ? authorizationUrl.href : String(authorizationUrl);
|
|
112
|
+
if (typeof onRedirect === 'function') { onRedirect(href); return; }
|
|
113
|
+
// audit: allowed — pre/non-UI OAuth flow prompt; the user must visit this URL.
|
|
114
|
+
process.stderr.write(`\nOpen this URL to authorize the MCP server "${server}":\n${href}\n\n`);
|
|
115
|
+
_openBrowser(href);
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Forget all stored OAuth material for a server (used by `mcp remove`/re-auth).
|
|
121
|
+
function clearOAuth(server, store = keychainStore()) {
|
|
122
|
+
let ok = true;
|
|
123
|
+
for (const kind of ['tokens', 'client', 'verifier']) {
|
|
124
|
+
if (!store.delete(`${server}:${kind}`)) ok = false;
|
|
125
|
+
}
|
|
126
|
+
return ok;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
module.exports = {
|
|
130
|
+
MCP_KEYCHAIN_SERVICE,
|
|
131
|
+
keychainStore,
|
|
132
|
+
createKeychainOAuthProvider,
|
|
133
|
+
clearOAuth,
|
|
134
|
+
};
|