@blockrun/runcode 1.8.0 → 2.1.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/dist/agent/compact.js +5 -4
- package/dist/agent/llm.js +6 -1
- package/dist/agent/loop.js +30 -4
- package/dist/agent/optimize.js +6 -1
- package/dist/agent/tokens.d.ts +19 -0
- package/dist/agent/tokens.js +56 -0
- package/dist/commands/start.js +22 -2
- package/dist/mcp/client.d.ts +44 -0
- package/dist/mcp/client.js +147 -0
- package/dist/mcp/config.d.ts +20 -0
- package/dist/mcp/config.js +116 -0
- package/dist/session/storage.d.ts +5 -1
- package/dist/session/storage.js +19 -3
- package/dist/tools/imagegen.js +5 -2
- package/dist/tools/write.js +10 -0
- package/package.json +2 -1
package/dist/agent/compact.js
CHANGED
|
@@ -136,10 +136,11 @@ async function compactHistory(history, model, client, debug) {
|
|
|
136
136
|
* Keeps the most recent tool exchange + the last few user/assistant turns.
|
|
137
137
|
*/
|
|
138
138
|
function findKeepBoundary(history) {
|
|
139
|
-
// Keep
|
|
140
|
-
|
|
141
|
-
const
|
|
142
|
-
|
|
139
|
+
// Keep the last 8-20 messages (absolute range, not percentage)
|
|
140
|
+
// Prevents "never compacts" bug when history grows large
|
|
141
|
+
const minKeep = Math.min(8, history.length);
|
|
142
|
+
const maxKeep = Math.min(20, history.length - 1);
|
|
143
|
+
let keep = Math.max(minKeep, Math.min(maxKeep, Math.ceil(history.length * 0.3)));
|
|
143
144
|
// Make sure we don't split in the middle of a tool exchange
|
|
144
145
|
// (assistant with tool_use must be followed by user with tool_result)
|
|
145
146
|
while (keep < history.length) {
|
package/dist/agent/llm.js
CHANGED
|
@@ -129,7 +129,12 @@ export class ModelClient {
|
|
|
129
129
|
try {
|
|
130
130
|
parsedInput = JSON.parse(currentToolInput || '{}');
|
|
131
131
|
}
|
|
132
|
-
catch {
|
|
132
|
+
catch (parseErr) {
|
|
133
|
+
// Log malformed JSON instead of silently defaulting to {}
|
|
134
|
+
if (this.debug) {
|
|
135
|
+
console.error(`[runcode] Malformed tool input JSON for ${currentToolName}: ${parseErr.message}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
133
138
|
const toolInvocation = {
|
|
134
139
|
type: 'tool_use',
|
|
135
140
|
id: currentToolId,
|
package/dist/agent/loop.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { ModelClient } from './llm.js';
|
|
7
7
|
import { autoCompactIfNeeded, forceCompact, microCompact } from './compact.js';
|
|
8
|
-
import { estimateHistoryTokens } from './tokens.js';
|
|
8
|
+
import { estimateHistoryTokens, updateActualTokens, resetTokenAnchor } from './tokens.js';
|
|
9
9
|
import { PermissionManager } from './permissions.js';
|
|
10
10
|
import { StreamingExecutor } from './streaming-executor.js';
|
|
11
11
|
import { optimizeHistory, CAPPED_MAX_TOKENS, ESCALATED_MAX_TOKENS } from './optimize.js';
|
|
@@ -215,7 +215,7 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
|
|
|
215
215
|
// Session persistence
|
|
216
216
|
const sessionId = createSessionId();
|
|
217
217
|
let turnCount = 0;
|
|
218
|
-
pruneOldSessions(); // Cleanup old sessions on start
|
|
218
|
+
pruneOldSessions(sessionId); // Cleanup old sessions on start, protect current
|
|
219
219
|
while (true) {
|
|
220
220
|
let input = await getUserInput();
|
|
221
221
|
if (input === null)
|
|
@@ -287,6 +287,26 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
|
|
|
287
287
|
onEvent({ kind: 'turn_done', reason: 'completed' });
|
|
288
288
|
continue;
|
|
289
289
|
}
|
|
290
|
+
// Handle /mcp — show connected MCP servers
|
|
291
|
+
if (input === '/mcp') {
|
|
292
|
+
const { listMcpServers } = await import('../mcp/client.js');
|
|
293
|
+
const servers = listMcpServers();
|
|
294
|
+
if (servers.length === 0) {
|
|
295
|
+
onEvent({ kind: 'text_delta', text: 'No MCP servers connected.\nAdd servers to `~/.blockrun/mcp.json` or `.mcp.json` in your project.\n' });
|
|
296
|
+
}
|
|
297
|
+
else {
|
|
298
|
+
let text = `**${servers.length} MCP server(s) connected:**\n\n`;
|
|
299
|
+
for (const s of servers) {
|
|
300
|
+
text += ` **${s.name}** — ${s.toolCount} tools\n`;
|
|
301
|
+
for (const t of s.tools) {
|
|
302
|
+
text += ` · ${t}\n`;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
onEvent({ kind: 'text_delta', text });
|
|
306
|
+
}
|
|
307
|
+
onEvent({ kind: 'turn_done', reason: 'completed' });
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
290
310
|
// Handle /bug — open issue tracker
|
|
291
311
|
if (input === '/bug') {
|
|
292
312
|
onEvent({ kind: 'text_delta', text: 'Report issues at: https://github.com/BlockRunAI/runcode/issues\n' });
|
|
@@ -480,7 +500,10 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
|
|
|
480
500
|
}
|
|
481
501
|
// Handle /context — show current session context info
|
|
482
502
|
if (input === '/context') {
|
|
483
|
-
const
|
|
503
|
+
const { getAnchoredTokenCount, getContextWindow } = await import('./tokens.js');
|
|
504
|
+
const { estimated, apiAnchored } = getAnchoredTokenCount(history);
|
|
505
|
+
const contextWindow = getContextWindow(config.model);
|
|
506
|
+
const usagePct = ((estimated / contextWindow) * 100).toFixed(1);
|
|
484
507
|
const msgs = history.length;
|
|
485
508
|
const model = config.model;
|
|
486
509
|
const dir = config.workingDir || process.cwd();
|
|
@@ -489,7 +512,7 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
|
|
|
489
512
|
` Model: ${model}\n` +
|
|
490
513
|
` Mode: ${mode}\n` +
|
|
491
514
|
` Messages: ${msgs}\n` +
|
|
492
|
-
` Tokens: ~${
|
|
515
|
+
` Tokens: ~${estimated.toLocaleString()} / ${(contextWindow / 1000).toFixed(0)}k (${usagePct}%)${apiAnchored ? ' ✓' : ' ~'}\n` +
|
|
493
516
|
` Session: ${sessionId}\n` +
|
|
494
517
|
` Directory: ${dir}\n`
|
|
495
518
|
});
|
|
@@ -605,6 +628,7 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
|
|
|
605
628
|
if (didCompact) {
|
|
606
629
|
history.length = 0;
|
|
607
630
|
history.push(...compacted);
|
|
631
|
+
resetTokenAnchor(); // Reset anchor after compaction — estimates will be used
|
|
608
632
|
if (config.debug) {
|
|
609
633
|
console.error(`[runcode] History compacted: ~${estimateHistoryTokens(history)} tokens`);
|
|
610
634
|
}
|
|
@@ -696,6 +720,8 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
|
|
|
696
720
|
onEvent({ kind: 'turn_done', reason: 'error', error: errMsg + suggestion });
|
|
697
721
|
break;
|
|
698
722
|
}
|
|
723
|
+
// Anchor token tracking to actual API counts
|
|
724
|
+
updateActualTokens(usage.inputTokens, usage.outputTokens, history.length);
|
|
699
725
|
onEvent({
|
|
700
726
|
kind: 'usage',
|
|
701
727
|
inputTokens: usage.inputTokens,
|
package/dist/agent/optimize.js
CHANGED
|
@@ -49,7 +49,12 @@ export function budgetToolResults(history) {
|
|
|
49
49
|
// Per-tool cap
|
|
50
50
|
if (size > MAX_TOOL_RESULT_CHARS) {
|
|
51
51
|
modified = true;
|
|
52
|
-
|
|
52
|
+
// Truncate at line boundary for cleaner output
|
|
53
|
+
let preview = content.slice(0, PREVIEW_CHARS);
|
|
54
|
+
const lastNewline = preview.lastIndexOf('\n');
|
|
55
|
+
if (lastNewline > PREVIEW_CHARS * 0.5) {
|
|
56
|
+
preview = preview.slice(0, lastNewline);
|
|
57
|
+
}
|
|
53
58
|
budgeted.push({
|
|
54
59
|
type: 'tool_result',
|
|
55
60
|
tool_use_id: part.tool_use_id,
|
package/dist/agent/tokens.d.ts
CHANGED
|
@@ -1,8 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Token estimation for runcode.
|
|
3
3
|
* Uses byte-based heuristic (no external tokenizer dependency).
|
|
4
|
+
* Anchors to actual API counts when available, estimates on top for new messages.
|
|
4
5
|
*/
|
|
5
6
|
import type { Dialogue } from './types.js';
|
|
7
|
+
/**
|
|
8
|
+
* Update with actual token counts from API response.
|
|
9
|
+
* This anchors our estimates to reality.
|
|
10
|
+
*/
|
|
11
|
+
export declare function updateActualTokens(inputTokens: number, outputTokens: number, messageCount: number): void;
|
|
12
|
+
/**
|
|
13
|
+
* Get token count using API anchor + estimation for new messages.
|
|
14
|
+
* More accurate than pure estimation because it's grounded in actual API counts.
|
|
15
|
+
*/
|
|
16
|
+
export declare function getAnchoredTokenCount(history: Dialogue[]): {
|
|
17
|
+
estimated: number;
|
|
18
|
+
apiAnchored: boolean;
|
|
19
|
+
contextUsagePct: number;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Reset anchor (e.g., after compaction).
|
|
23
|
+
*/
|
|
24
|
+
export declare function resetTokenAnchor(): void;
|
|
6
25
|
/**
|
|
7
26
|
* Estimate token count for a string using byte-length heuristic.
|
|
8
27
|
* JSON-heavy content uses 2 bytes/token; general text uses 4.
|
package/dist/agent/tokens.js
CHANGED
|
@@ -1,8 +1,64 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Token estimation for runcode.
|
|
3
3
|
* Uses byte-based heuristic (no external tokenizer dependency).
|
|
4
|
+
* Anchors to actual API counts when available, estimates on top for new messages.
|
|
4
5
|
*/
|
|
5
6
|
const DEFAULT_BYTES_PER_TOKEN = 4;
|
|
7
|
+
// ─── API-anchored token tracking ───────────────────────���──────────────────
|
|
8
|
+
/** Last known actual token count from API response */
|
|
9
|
+
let lastApiInputTokens = 0;
|
|
10
|
+
let lastApiOutputTokens = 0;
|
|
11
|
+
let lastApiMessageCount = 0;
|
|
12
|
+
/**
|
|
13
|
+
* Update with actual token counts from API response.
|
|
14
|
+
* This anchors our estimates to reality.
|
|
15
|
+
*/
|
|
16
|
+
export function updateActualTokens(inputTokens, outputTokens, messageCount) {
|
|
17
|
+
lastApiInputTokens = inputTokens;
|
|
18
|
+
lastApiOutputTokens = outputTokens;
|
|
19
|
+
lastApiMessageCount = messageCount;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Get token count using API anchor + estimation for new messages.
|
|
23
|
+
* More accurate than pure estimation because it's grounded in actual API counts.
|
|
24
|
+
*/
|
|
25
|
+
export function getAnchoredTokenCount(history) {
|
|
26
|
+
if (lastApiInputTokens > 0 && lastApiMessageCount > 0 && history.length >= lastApiMessageCount) {
|
|
27
|
+
// Sanity check: if history was mutated (compaction, micro-compact), anchor may be stale.
|
|
28
|
+
// Detect by checking if new messages were only appended (length grew), not if content changed.
|
|
29
|
+
// If history grew by more than expected (e.g., resume injected many messages), fall through to estimation.
|
|
30
|
+
const growth = history.length - lastApiMessageCount;
|
|
31
|
+
if (growth <= 20) { // Reasonable growth since last API call
|
|
32
|
+
const newMessages = history.slice(lastApiMessageCount);
|
|
33
|
+
let newTokens = 0;
|
|
34
|
+
for (const msg of newMessages) {
|
|
35
|
+
newTokens += estimateDialogueTokens(msg);
|
|
36
|
+
}
|
|
37
|
+
const total = lastApiInputTokens + newTokens;
|
|
38
|
+
return {
|
|
39
|
+
estimated: total,
|
|
40
|
+
apiAnchored: true,
|
|
41
|
+
contextUsagePct: 0,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
// Too much growth — anchor is unreliable, fall through to estimation
|
|
45
|
+
resetTokenAnchor();
|
|
46
|
+
}
|
|
47
|
+
// No anchor — pure estimation
|
|
48
|
+
return {
|
|
49
|
+
estimated: estimateHistoryTokens(history),
|
|
50
|
+
apiAnchored: false,
|
|
51
|
+
contextUsagePct: 0,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Reset anchor (e.g., after compaction).
|
|
56
|
+
*/
|
|
57
|
+
export function resetTokenAnchor() {
|
|
58
|
+
lastApiInputTokens = 0;
|
|
59
|
+
lastApiOutputTokens = 0;
|
|
60
|
+
lastApiMessageCount = 0;
|
|
61
|
+
}
|
|
6
62
|
/**
|
|
7
63
|
* Estimate token count for a string using byte-length heuristic.
|
|
8
64
|
* JSON-heavy content uses 2 bytes/token; general text uses 4.
|
package/dist/commands/start.js
CHANGED
|
@@ -9,6 +9,8 @@ import { interactiveSession } from '../agent/loop.js';
|
|
|
9
9
|
import { allCapabilities, createSubAgentCapability } from '../tools/index.js';
|
|
10
10
|
import { launchInkUI } from '../ui/app.js';
|
|
11
11
|
import { pickModel, resolveModel } from '../ui/model-picker.js';
|
|
12
|
+
import { loadMcpConfig } from '../mcp/config.js';
|
|
13
|
+
import { connectMcpServers, disconnectMcpServers } from '../mcp/client.js';
|
|
12
14
|
export async function startCommand(options) {
|
|
13
15
|
const version = options.version ?? '1.0.0';
|
|
14
16
|
const chain = loadChain();
|
|
@@ -92,9 +94,26 @@ export async function startCommand(options) {
|
|
|
92
94
|
})();
|
|
93
95
|
// Assemble system instructions
|
|
94
96
|
const systemInstructions = assembleInstructions(workDir);
|
|
95
|
-
//
|
|
97
|
+
// Connect MCP servers (non-blocking — add tools if servers are available)
|
|
98
|
+
const mcpConfig = loadMcpConfig(workDir);
|
|
99
|
+
let mcpTools = [];
|
|
100
|
+
const mcpServerCount = Object.keys(mcpConfig.mcpServers).filter(k => !mcpConfig.mcpServers[k].disabled).length;
|
|
101
|
+
if (mcpServerCount > 0) {
|
|
102
|
+
try {
|
|
103
|
+
mcpTools = await connectMcpServers(mcpConfig, options.debug);
|
|
104
|
+
if (mcpTools.length > 0) {
|
|
105
|
+
console.log(chalk.dim(` MCP: ${mcpTools.length} tools from ${mcpServerCount} server(s)`));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
if (options.debug) {
|
|
110
|
+
console.error(chalk.yellow(` MCP error: ${err.message}`));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// Build capabilities (built-in + MCP + sub-agent)
|
|
96
115
|
const subAgent = createSubAgentCapability(apiUrl, chain, allCapabilities);
|
|
97
|
-
const capabilities = [...allCapabilities, subAgent];
|
|
116
|
+
const capabilities = [...allCapabilities, ...mcpTools, subAgent];
|
|
98
117
|
// Agent config
|
|
99
118
|
const agentConfig = {
|
|
100
119
|
model,
|
|
@@ -149,6 +168,7 @@ async function runWithInkUI(agentConfig, model, workDir, version, walletInfo, on
|
|
|
149
168
|
}
|
|
150
169
|
ui.cleanup();
|
|
151
170
|
flushStats();
|
|
171
|
+
await disconnectMcpServers();
|
|
152
172
|
console.log(chalk.dim('\nGoodbye.\n'));
|
|
153
173
|
process.exit(0);
|
|
154
174
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Client for runcode.
|
|
3
|
+
* Connects to MCP servers, discovers tools, and wraps them as CapabilityHandlers.
|
|
4
|
+
* Supports stdio and HTTP (SSE) transports.
|
|
5
|
+
*/
|
|
6
|
+
import type { CapabilityHandler } from '../agent/types.js';
|
|
7
|
+
export interface McpServerConfig {
|
|
8
|
+
/** Transport type */
|
|
9
|
+
transport: 'stdio' | 'http';
|
|
10
|
+
/** For stdio: command to run */
|
|
11
|
+
command?: string;
|
|
12
|
+
/** For stdio: arguments */
|
|
13
|
+
args?: string[];
|
|
14
|
+
/** For stdio: environment variables */
|
|
15
|
+
env?: Record<string, string>;
|
|
16
|
+
/** For http: server URL */
|
|
17
|
+
url?: string;
|
|
18
|
+
/** For http: headers */
|
|
19
|
+
headers?: Record<string, string>;
|
|
20
|
+
/** Human-readable label */
|
|
21
|
+
label?: string;
|
|
22
|
+
/** Disable this server */
|
|
23
|
+
disabled?: boolean;
|
|
24
|
+
}
|
|
25
|
+
export interface McpConfig {
|
|
26
|
+
mcpServers: Record<string, McpServerConfig>;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Connect to all configured MCP servers and return discovered tools.
|
|
30
|
+
* Each connection has a 5s timeout to avoid blocking startup.
|
|
31
|
+
*/
|
|
32
|
+
export declare function connectMcpServers(config: McpConfig, debug?: boolean): Promise<CapabilityHandler[]>;
|
|
33
|
+
/**
|
|
34
|
+
* Disconnect all MCP servers.
|
|
35
|
+
*/
|
|
36
|
+
export declare function disconnectMcpServers(): Promise<void>;
|
|
37
|
+
/**
|
|
38
|
+
* List connected MCP servers and their tools.
|
|
39
|
+
*/
|
|
40
|
+
export declare function listMcpServers(): Array<{
|
|
41
|
+
name: string;
|
|
42
|
+
toolCount: number;
|
|
43
|
+
tools: string[];
|
|
44
|
+
}>;
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Client for runcode.
|
|
3
|
+
* Connects to MCP servers, discovers tools, and wraps them as CapabilityHandlers.
|
|
4
|
+
* Supports stdio and HTTP (SSE) transports.
|
|
5
|
+
*/
|
|
6
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
7
|
+
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
8
|
+
// ─── Connection Management ────────────────────────────────────────────────
|
|
9
|
+
const connections = new Map();
|
|
10
|
+
/**
|
|
11
|
+
* Connect to an MCP server via stdio transport.
|
|
12
|
+
* Discovers tools and returns them as CapabilityHandlers.
|
|
13
|
+
*/
|
|
14
|
+
async function connectStdio(name, config) {
|
|
15
|
+
if (!config.command) {
|
|
16
|
+
throw new Error(`MCP server "${name}" missing command`);
|
|
17
|
+
}
|
|
18
|
+
const transport = new StdioClientTransport({
|
|
19
|
+
command: config.command,
|
|
20
|
+
args: config.args || [],
|
|
21
|
+
env: { ...process.env, ...(config.env || {}) },
|
|
22
|
+
});
|
|
23
|
+
const client = new Client({ name: `runcode-mcp-${name}`, version: '1.0.0' }, { capabilities: {} });
|
|
24
|
+
try {
|
|
25
|
+
await client.connect(transport);
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
// Clean up transport if connect fails to prevent resource leak
|
|
29
|
+
try {
|
|
30
|
+
await transport.close();
|
|
31
|
+
}
|
|
32
|
+
catch { /* ignore */ }
|
|
33
|
+
throw err;
|
|
34
|
+
}
|
|
35
|
+
// Discover tools
|
|
36
|
+
const { tools: mcpTools } = await client.listTools();
|
|
37
|
+
const capabilities = [];
|
|
38
|
+
for (const tool of mcpTools) {
|
|
39
|
+
const toolName = `mcp__${name}__${tool.name}`;
|
|
40
|
+
const toolDescription = (tool.description || '').slice(0, 2048);
|
|
41
|
+
capabilities.push({
|
|
42
|
+
spec: {
|
|
43
|
+
name: toolName,
|
|
44
|
+
description: toolDescription || `MCP tool from ${name}`,
|
|
45
|
+
input_schema: tool.inputSchema || {
|
|
46
|
+
type: 'object',
|
|
47
|
+
properties: {},
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
execute: async (input, _ctx) => {
|
|
51
|
+
const MCP_TOOL_TIMEOUT = 30_000;
|
|
52
|
+
try {
|
|
53
|
+
// Timeout protection: if tool hangs, don't block the agent forever
|
|
54
|
+
const callPromise = client.callTool({ name: tool.name, arguments: input });
|
|
55
|
+
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error(`MCP tool timeout after ${MCP_TOOL_TIMEOUT / 1000}s`)), MCP_TOOL_TIMEOUT));
|
|
56
|
+
const result = await Promise.race([callPromise, timeoutPromise]);
|
|
57
|
+
// Extract text content from MCP response
|
|
58
|
+
const output = result.content
|
|
59
|
+
?.filter(c => c.type === 'text')
|
|
60
|
+
?.map(c => c.text)
|
|
61
|
+
?.join('\n') || JSON.stringify(result.content);
|
|
62
|
+
return {
|
|
63
|
+
output,
|
|
64
|
+
isError: result.isError === true,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
return {
|
|
69
|
+
output: `MCP tool error (${name}/${tool.name}): ${err.message}`,
|
|
70
|
+
isError: true,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
concurrent: true, // MCP tools are safe to run concurrently
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
const connected = { name, client, transport, tools: capabilities };
|
|
78
|
+
connections.set(name, connected);
|
|
79
|
+
return connected;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Connect to all configured MCP servers and return discovered tools.
|
|
83
|
+
*/
|
|
84
|
+
const MCP_CONNECT_TIMEOUT = 5_000; // 5s per server connection
|
|
85
|
+
/**
|
|
86
|
+
* Connect to all configured MCP servers and return discovered tools.
|
|
87
|
+
* Each connection has a 5s timeout to avoid blocking startup.
|
|
88
|
+
*/
|
|
89
|
+
export async function connectMcpServers(config, debug) {
|
|
90
|
+
const allTools = [];
|
|
91
|
+
for (const [name, serverConfig] of Object.entries(config.mcpServers)) {
|
|
92
|
+
if (serverConfig.disabled)
|
|
93
|
+
continue;
|
|
94
|
+
try {
|
|
95
|
+
if (debug) {
|
|
96
|
+
console.error(`[runcode] Connecting to MCP server: ${name}...`);
|
|
97
|
+
}
|
|
98
|
+
if (serverConfig.transport !== 'stdio') {
|
|
99
|
+
if (debug) {
|
|
100
|
+
console.error(`[runcode] MCP HTTP transport not yet supported for ${name}`);
|
|
101
|
+
}
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
// Timeout: don't let a slow server block startup
|
|
105
|
+
const connectPromise = connectStdio(name, serverConfig);
|
|
106
|
+
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('connection timeout (5s)')), MCP_CONNECT_TIMEOUT));
|
|
107
|
+
const connected = await Promise.race([connectPromise, timeoutPromise]);
|
|
108
|
+
allTools.push(...connected.tools);
|
|
109
|
+
if (debug) {
|
|
110
|
+
console.error(`[runcode] MCP ${name}: ${connected.tools.length} tools discovered`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
// Graceful degradation — log and continue without this server
|
|
115
|
+
console.error(`[runcode] MCP ${name} failed: ${err.message}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return allTools;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Disconnect all MCP servers.
|
|
122
|
+
*/
|
|
123
|
+
export async function disconnectMcpServers() {
|
|
124
|
+
for (const [name, conn] of connections) {
|
|
125
|
+
try {
|
|
126
|
+
await conn.client.close();
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
// Ignore cleanup errors
|
|
130
|
+
}
|
|
131
|
+
connections.delete(name);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* List connected MCP servers and their tools.
|
|
136
|
+
*/
|
|
137
|
+
export function listMcpServers() {
|
|
138
|
+
const result = [];
|
|
139
|
+
for (const [name, conn] of connections) {
|
|
140
|
+
result.push({
|
|
141
|
+
name,
|
|
142
|
+
toolCount: conn.tools.length,
|
|
143
|
+
tools: conn.tools.map(t => t.spec.name),
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
return result;
|
|
147
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP configuration management for runcode.
|
|
3
|
+
* Loads MCP server configs from:
|
|
4
|
+
* 1. Global: ~/.blockrun/mcp.json
|
|
5
|
+
* 2. Project: .mcp.json in working directory
|
|
6
|
+
*/
|
|
7
|
+
import type { McpConfig, McpServerConfig } from './client.js';
|
|
8
|
+
export declare function loadMcpConfig(workDir: string): McpConfig;
|
|
9
|
+
/**
|
|
10
|
+
* Save a server config to the global MCP config.
|
|
11
|
+
*/
|
|
12
|
+
export declare function saveMcpServer(name: string, config: McpServerConfig): void;
|
|
13
|
+
/**
|
|
14
|
+
* Remove a server from the global MCP config.
|
|
15
|
+
*/
|
|
16
|
+
export declare function removeMcpServer(name: string): boolean;
|
|
17
|
+
/**
|
|
18
|
+
* Trust a project directory to load its .mcp.json.
|
|
19
|
+
*/
|
|
20
|
+
export declare function trustProjectDir(workDir: string): void;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP configuration management for runcode.
|
|
3
|
+
* Loads MCP server configs from:
|
|
4
|
+
* 1. Global: ~/.blockrun/mcp.json
|
|
5
|
+
* 2. Project: .mcp.json in working directory
|
|
6
|
+
*/
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { BLOCKRUN_DIR } from '../config.js';
|
|
10
|
+
const GLOBAL_MCP_FILE = path.join(BLOCKRUN_DIR, 'mcp.json');
|
|
11
|
+
/**
|
|
12
|
+
* Load MCP server configurations from global + project files.
|
|
13
|
+
* Project config overrides global for same server name.
|
|
14
|
+
*/
|
|
15
|
+
// Built-in MCP server: @blockrun/mcp is always available (zero config)
|
|
16
|
+
const BUILTIN_MCP_SERVERS = {
|
|
17
|
+
blockrun: {
|
|
18
|
+
transport: 'stdio',
|
|
19
|
+
command: 'npx',
|
|
20
|
+
args: ['-y', '@blockrun/mcp'],
|
|
21
|
+
label: 'BlockRun (built-in)',
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
export function loadMcpConfig(workDir) {
|
|
25
|
+
// Start with built-in servers
|
|
26
|
+
const servers = { ...BUILTIN_MCP_SERVERS };
|
|
27
|
+
// 1. Global config
|
|
28
|
+
try {
|
|
29
|
+
if (fs.existsSync(GLOBAL_MCP_FILE)) {
|
|
30
|
+
const raw = JSON.parse(fs.readFileSync(GLOBAL_MCP_FILE, 'utf-8'));
|
|
31
|
+
if (raw.mcpServers && typeof raw.mcpServers === 'object') {
|
|
32
|
+
Object.assign(servers, raw.mcpServers);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// Ignore corrupt global config
|
|
38
|
+
}
|
|
39
|
+
// 2. Project config (.mcp.json in working directory)
|
|
40
|
+
// Security: project configs can execute arbitrary commands via stdio transport.
|
|
41
|
+
// Only load if a trust marker exists (user has explicitly opted in).
|
|
42
|
+
const projectMcpFile = path.join(workDir, '.mcp.json');
|
|
43
|
+
const trustMarker = path.join(BLOCKRUN_DIR, 'trusted-projects.json');
|
|
44
|
+
try {
|
|
45
|
+
if (fs.existsSync(projectMcpFile)) {
|
|
46
|
+
// Check if this project directory is trusted
|
|
47
|
+
let trusted = false;
|
|
48
|
+
try {
|
|
49
|
+
if (fs.existsSync(trustMarker)) {
|
|
50
|
+
const trustedDirs = JSON.parse(fs.readFileSync(trustMarker, 'utf-8'));
|
|
51
|
+
trusted = Array.isArray(trustedDirs) && trustedDirs.includes(workDir);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
catch { /* not trusted */ }
|
|
55
|
+
if (trusted) {
|
|
56
|
+
const raw = JSON.parse(fs.readFileSync(projectMcpFile, 'utf-8'));
|
|
57
|
+
if (raw.mcpServers && typeof raw.mcpServers === 'object') {
|
|
58
|
+
Object.assign(servers, raw.mcpServers);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// If not trusted, silently skip project config (user must run /mcp trust)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// Ignore corrupt project config
|
|
66
|
+
}
|
|
67
|
+
return { mcpServers: servers };
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Save a server config to the global MCP config.
|
|
71
|
+
*/
|
|
72
|
+
export function saveMcpServer(name, config) {
|
|
73
|
+
const existing = loadGlobalMcpConfig();
|
|
74
|
+
existing.mcpServers[name] = config;
|
|
75
|
+
fs.mkdirSync(BLOCKRUN_DIR, { recursive: true });
|
|
76
|
+
fs.writeFileSync(GLOBAL_MCP_FILE, JSON.stringify(existing, null, 2) + '\n');
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Remove a server from the global MCP config.
|
|
80
|
+
*/
|
|
81
|
+
export function removeMcpServer(name) {
|
|
82
|
+
const existing = loadGlobalMcpConfig();
|
|
83
|
+
if (!(name in existing.mcpServers))
|
|
84
|
+
return false;
|
|
85
|
+
delete existing.mcpServers[name];
|
|
86
|
+
fs.writeFileSync(GLOBAL_MCP_FILE, JSON.stringify(existing, null, 2) + '\n');
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Trust a project directory to load its .mcp.json.
|
|
91
|
+
*/
|
|
92
|
+
export function trustProjectDir(workDir) {
|
|
93
|
+
const trustMarker = path.join(BLOCKRUN_DIR, 'trusted-projects.json');
|
|
94
|
+
let trusted = [];
|
|
95
|
+
try {
|
|
96
|
+
if (fs.existsSync(trustMarker)) {
|
|
97
|
+
trusted = JSON.parse(fs.readFileSync(trustMarker, 'utf-8'));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
catch { /* fresh */ }
|
|
101
|
+
if (!trusted.includes(workDir)) {
|
|
102
|
+
trusted.push(workDir);
|
|
103
|
+
fs.mkdirSync(BLOCKRUN_DIR, { recursive: true });
|
|
104
|
+
fs.writeFileSync(trustMarker, JSON.stringify(trusted, null, 2));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function loadGlobalMcpConfig() {
|
|
108
|
+
try {
|
|
109
|
+
if (fs.existsSync(GLOBAL_MCP_FILE)) {
|
|
110
|
+
const raw = JSON.parse(fs.readFileSync(GLOBAL_MCP_FILE, 'utf-8'));
|
|
111
|
+
return { mcpServers: raw.mcpServers || {} };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
catch { /* fresh */ }
|
|
115
|
+
return { mcpServers: {} };
|
|
116
|
+
}
|
|
@@ -39,4 +39,8 @@ export declare function listSessions(): SessionMeta[];
|
|
|
39
39
|
/**
|
|
40
40
|
* Prune old sessions beyond MAX_SESSIONS.
|
|
41
41
|
*/
|
|
42
|
-
|
|
42
|
+
/**
|
|
43
|
+
* Prune old sessions beyond MAX_SESSIONS.
|
|
44
|
+
* Accepts optional activeSessionId to protect from deletion.
|
|
45
|
+
*/
|
|
46
|
+
export declare function pruneOldSessions(activeSessionId?: string): void;
|
package/dist/session/storage.js
CHANGED
|
@@ -67,7 +67,17 @@ export function loadSessionHistory(sessionId) {
|
|
|
67
67
|
try {
|
|
68
68
|
const content = fs.readFileSync(sessionPath(sessionId), 'utf-8');
|
|
69
69
|
const lines = content.trim().split('\n').filter(Boolean);
|
|
70
|
-
|
|
70
|
+
const results = [];
|
|
71
|
+
for (const line of lines) {
|
|
72
|
+
try {
|
|
73
|
+
results.push(JSON.parse(line));
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
// Skip corrupted lines — partial writes from crashes
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return results;
|
|
71
81
|
}
|
|
72
82
|
catch {
|
|
73
83
|
return [];
|
|
@@ -98,11 +108,17 @@ export function listSessions() {
|
|
|
98
108
|
/**
|
|
99
109
|
* Prune old sessions beyond MAX_SESSIONS.
|
|
100
110
|
*/
|
|
101
|
-
|
|
111
|
+
/**
|
|
112
|
+
* Prune old sessions beyond MAX_SESSIONS.
|
|
113
|
+
* Accepts optional activeSessionId to protect from deletion.
|
|
114
|
+
*/
|
|
115
|
+
export function pruneOldSessions(activeSessionId) {
|
|
102
116
|
const sessions = listSessions();
|
|
103
117
|
if (sessions.length <= MAX_SESSIONS)
|
|
104
118
|
return;
|
|
105
|
-
const toDelete = sessions
|
|
119
|
+
const toDelete = sessions
|
|
120
|
+
.slice(MAX_SESSIONS)
|
|
121
|
+
.filter(s => s.id !== activeSessionId); // Never delete active session
|
|
106
122
|
for (const s of toDelete) {
|
|
107
123
|
try {
|
|
108
124
|
fs.unlinkSync(sessionPath(s.id));
|
package/dist/tools/imagegen.js
CHANGED
|
@@ -70,8 +70,11 @@ async function execute(input, ctx) {
|
|
|
70
70
|
fs.writeFileSync(outPath, buffer);
|
|
71
71
|
}
|
|
72
72
|
else if (imageData.url) {
|
|
73
|
-
// Download from URL
|
|
74
|
-
const
|
|
73
|
+
// Download from URL (with 30s timeout)
|
|
74
|
+
const dlCtrl = new AbortController();
|
|
75
|
+
const dlTimeout = setTimeout(() => dlCtrl.abort(), 30_000);
|
|
76
|
+
const imgResp = await fetch(imageData.url, { signal: dlCtrl.signal });
|
|
77
|
+
clearTimeout(dlTimeout);
|
|
75
78
|
const buffer = Buffer.from(await imgResp.arrayBuffer());
|
|
76
79
|
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
77
80
|
fs.writeFileSync(outPath, buffer);
|
package/dist/tools/write.js
CHANGED
|
@@ -40,6 +40,16 @@ async function execute(input, ctx) {
|
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
catch { /* parent doesn't exist yet, will be created */ }
|
|
43
|
+
// Also check if target file itself is a symlink to a sensitive location
|
|
44
|
+
try {
|
|
45
|
+
if (fs.existsSync(resolved) && fs.lstatSync(resolved).isSymbolicLink()) {
|
|
46
|
+
const realTarget = fs.realpathSync(resolved);
|
|
47
|
+
if (checkPath(realTarget)) {
|
|
48
|
+
return { output: `Error: refusing to write — symlink resolves to sensitive location: ${realTarget}`, isError: true };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
catch { /* file doesn't exist yet, ok */ }
|
|
43
53
|
try {
|
|
44
54
|
// Ensure parent directory exists
|
|
45
55
|
const parentDir = path.dirname(resolved);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@blockrun/runcode",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "RunCode — AI coding agent powered by 41+ models. Pay per use with USDC.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
45
|
"@blockrun/llm": "^1.4.2",
|
|
46
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
46
47
|
"@solana/spl-token": "^0.4.14",
|
|
47
48
|
"@solana/web3.js": "^1.98.4",
|
|
48
49
|
"@types/react": "^19.2.14",
|