Files
rs-sdk/mcp/server.ts
T
max bittkerandClaude Opus 5.5 df3d8888ae feat(sdk,mcp): cancellable execute_code, bot.mineRock, bounded walkTo
execute_code
- Cancellation now reaches inside the SDK via an AsyncLocalStorage scope:
  timeout, MCP cancel, or a newer call rejects pending waits and blocks
  further action packets, including those inside walkTo().
- One execution per bot: a new call supersedes the running one and starts
  once it has unwound; disconnect_bot does the same.
- Cancelled user code parks on bot/sdk calls instead of throwing, so a
  catch-all loop can't spin and starve the server.
- Console capture is routed per execution, so concurrent bots no longer
  clobber each other's output; leftover logs go to stderr.
- Stale world state warns and still runs, so a stale client can be diagnosed.
- Credentials are redacted from logs, results and launch messages.

SDK
- bot.mineRock() waits for ore plus Mining XP; interactLoc docs note that
  success can mean the action only started.
- walkTo() takes options.timeout (default 120s), refuses stale state, and
  reports the real last position.
- runScript uses the same cancellation scope for timeouts and signals.

Docs trimmed of duplicated or stale execute_code text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:30:19 -04:00

229 lines
7.1 KiB
TypeScript

#!/usr/bin/env bun
/**
* MCP Code Execution Server for RS-Agent
*
* Manages multiple bot connections dynamically at runtime.
* Agents can connect, disconnect, and execute code on any connected bot.
*/
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ListResourcesRequestSchema,
ReadResourceRequestSchema,
ListToolsRequestSchema
} from '@modelcontextprotocol/sdk/types.js';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { botManager } from './api/index.js';
import { initPathfinding } from '../sdk/pathfinding.js';
import { marketTool, readMarket } from './market-tools.js';
import { installExecutionConsoleCapture } from './console-capture.js';
import { executeCode, botExecutions } from './execute-code.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Create MCP server
const server = new Server(
{
name: 'rs-agent-bot',
version: '2.0.0'
},
{
capabilities: {
resources: {},
tools: {}
}
}
);
// List available API modules as resources
server.setRequestHandler(ListResourcesRequestSchema, async () => {
return {
resources: [
{
uri: 'file://../sdk/API.md',
name: 'SDK API Reference',
description: 'Auto-generated reference for bot.* (high-level actions: chopTree, walkTo, attack, openBank, ...) and sdk.* (low-level: getState, sendWalk, findNearbyNpc, ...).',
mimeType: 'text/markdown'
},
{uri: 'file://../sdk/MARKET.md', name: 'Grand Exchange guide', description: 'Public market reads, physical trading, offer receipts and recovery after uncertain outcomes.', mimeType: 'text/markdown'}
]
};
});
// Read API module contents
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
try {
const uri = request.params.uri;
let filePath: string;
if (uri.startsWith('file://')) {
const relativePath = uri.replace('file://', '');
filePath = join(__dirname, relativePath);
} else {
throw new Error(`Unsupported URI scheme: ${uri}`);
}
const content = await Bun.file(filePath).text();
return {
contents: [
{
uri: request.params.uri,
mimeType: 'text/plain',
text: content
}
]
};
} catch (error: any) {
throw new Error(`Failed to read resource: ${error.message}`);
}
});
// List available tools
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
marketTool,
{
name: 'execute_code',
description: 'Execute TypeScript code on a bot. Auto-connects using credentials from bots/{name}/bot.env. The code runs in an async context with bot (BotActions), sdk (BotSDK), and Spells (spell component ids) available. A new call for the same bot cancels the one still running. Timeout or explicit MCP cancellation stops SDK dispatches and observations, including inside high-level actions. Keep interactive snippets short; use a saved script with the existing browser client for long loops.',
inputSchema: {
type: 'object',
properties: {
bot_name: {
type: 'string',
description: 'Bot name (matches folder in bots/). Auto-connects on first use.'
},
code: {
type: 'string',
description: 'TypeScript code to execute (type annotations are stripped before running). Available globals: bot (BotActions), sdk (BotSDK), Spells (e.g. Spells.HIGH_ALCHEMY). Example: "const r = await bot.chopTree(); return r;". A world-state summary (position, skills, inventory, nearby entities, open dialogs, new messages) is appended to every result, so there is no need to return sdk.getState() just to look around.'
},
timeout: {
type: 'number',
description: 'Execution timeout in minutes (default: 2, max: 60).'
}
},
required: ['bot_name', 'code']
}
},
{
name: 'disconnect_bot',
description: 'Disconnect a connected bot',
inputSchema: {
type: 'object',
properties: {
name: {
type: 'string',
description: 'Bot name to disconnect'
}
},
required: ['name']
}
},
{
name: 'list_bots',
description: 'List all connected bots',
inputSchema: {
type: 'object',
properties: {}
}
}
]
};
});
// Handle tool calls
server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
const { name, arguments: args } = request.params;
try {
switch (name) {
case 'get_market': return successResponse(await readMarket(args ?? {}));
case 'disconnect_bot': {
const botName = args?.name as string;
if (!botName) {
return errorResponse('Bot name is required');
}
// Supersedes any execution still running on this bot.
await botExecutions.run(botName, extra.signal, async () => botManager.disconnect(botName));
return successResponse({ message: `Disconnected bot "${botName}"` });
}
case 'list_bots': {
const bots = botManager.list();
return successResponse({
bots,
count: bots.length
});
}
case 'execute_code': {
const botName = args?.bot_name as string;
const code = args?.code as string;
if (!botName) {
return errorResponse('bot_name is required');
}
if (!code) {
return errorResponse('code is required');
}
const timeoutMinutes = Math.min(Math.max((args?.timeout as number) || 2, 0.1), 60);
return executeCode(botManager, botName, code, timeoutMinutes * 60_000, extra.signal);
}
default:
throw new Error(`Unknown tool: ${name}`);
}
} catch (error: any) {
const errorMessage = `Error: ${error.message}\n\nStack trace:\n${error.stack}`;
return {
content: [{ type: 'text', text: errorMessage }],
isError: true
};
}
});
function successResponse(data: any) {
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
}
function errorResponse(message: string) {
return {
content: [{ type: 'text', text: `Error: ${message}` }],
isError: true
};
}
// Start server
async function main() {
installExecutionConsoleCapture();
console.error('[MCP Server] Starting RS-Agent MCP server v2.0...');
console.error('[MCP Server] Bots auto-connect on the first execute_code call.');
// Warm the pathfinder before any live session exists: init is fully
// synchronous (tens of seconds on slow hosts), and paying it lazily inside
// a walkTo blocks the event loop until the gateway drops the connection.
initPathfinding();
const transport = new StdioServerTransport();
await server.connect(transport);
console.error('[MCP Server] Server running on stdio');
}
main().catch((error) => {
console.error('[MCP Server] Fatal error:', error);
process.exit(1);
});