Extract memind_adm admin server, add local dev tooling, and remove image-generation.
Split platform admin and ops APIs into standalone admin-server.mjs with network guards; simplify billing to RMB token pricing, refactor user auth, and add rsync deploy plus local-test scripts and docs. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
#!/usr/bin/env node
|
||||
// Stdio MCP server: sandboxes file operations to SANDBOX_ROOT.
|
||||
// Used as a goosed extension to replace the built-in developer extension for
|
||||
// regular users — enforces path boundaries at the OS level rather than relying
|
||||
// on AI model compliance with text constraints.
|
||||
//
|
||||
// SANDBOX_ROOT is passed as argv[2] (primary) or SANDBOX_ROOT env var (fallback).
|
||||
// argv[2] is preferred because some goosed versions don't forward env to stdio MCPs.
|
||||
// Optional env: ALLOWED_TOOLS — comma-separated tool whitelist (default: all tools)
|
||||
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import readline from 'node:readline';
|
||||
|
||||
const SANDBOX_ROOT = process.argv[2]?.trim() || process.env.SANDBOX_ROOT?.trim();
|
||||
if (!SANDBOX_ROOT) {
|
||||
process.stderr.write('[mindspace-sandbox-mcp] SANDBOX_ROOT is not set — refusing to start\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const SANDBOX = path.resolve(SANDBOX_ROOT);
|
||||
|
||||
const allowedToolsEnv = process.env.ALLOWED_TOOLS?.trim();
|
||||
const ALLOWED_TOOLS = allowedToolsEnv ? new Set(allowedToolsEnv.split(',').map((s) => s.trim())) : null;
|
||||
|
||||
/** Resolve and validate that the path is inside SANDBOX. Returns absolute path. */
|
||||
function resolveSandboxed(p) {
|
||||
if (!p || typeof p !== 'string') throw new Error('路径参数无效');
|
||||
const resolved = path.isAbsolute(p) ? path.resolve(p) : path.resolve(SANDBOX, p);
|
||||
if (resolved !== SANDBOX && !resolved.startsWith(SANDBOX + path.sep)) {
|
||||
throw Object.assign(new Error(`路径越界:${p} 不在当前工作区内`), { code: 'EACCES' });
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
const ALL_TOOLS = [
|
||||
{
|
||||
name: 'read_file',
|
||||
description: '读取文件内容(仅限工作区内的文件)',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: '文件路径(相对工作区或绝对路径)' },
|
||||
},
|
||||
required: ['path'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'write_file',
|
||||
description: '写入文件内容(仅限工作区内的文件;不存在则创建,已存在则覆盖)',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: '文件路径' },
|
||||
content: { type: 'string', description: '文件内容' },
|
||||
},
|
||||
required: ['path', 'content'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'edit_file',
|
||||
description: '将文件中的旧内容替换为新内容(仅限工作区内的文件)',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: '文件路径' },
|
||||
old_str: { type: 'string', description: '要替换的原始内容(必须在文件中唯一)' },
|
||||
new_str: { type: 'string', description: '替换后的新内容' },
|
||||
},
|
||||
required: ['path', 'old_str', 'new_str'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'list_dir',
|
||||
description: '列出目录内容(仅限工作区内的目录)',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: {
|
||||
type: 'string',
|
||||
description: '目录路径(省略则列出工作区根目录)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'create_dir',
|
||||
description: '创建目录(仅限工作区内;已存在不报错)',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: '目录路径' },
|
||||
},
|
||||
required: ['path'],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const TOOLS = ALLOWED_TOOLS ? ALL_TOOLS.filter((t) => ALLOWED_TOOLS.has(t.name)) : ALL_TOOLS;
|
||||
|
||||
function callTool(name, args) {
|
||||
if (ALLOWED_TOOLS && !ALLOWED_TOOLS.has(name)) {
|
||||
throw new Error(`工具 ${name} 未授权`);
|
||||
}
|
||||
switch (name) {
|
||||
case 'read_file': {
|
||||
const abs = resolveSandboxed(args.path);
|
||||
const content = fs.readFileSync(abs, 'utf8');
|
||||
return [{ type: 'text', text: content }];
|
||||
}
|
||||
case 'write_file': {
|
||||
const abs = resolveSandboxed(args.path);
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
fs.writeFileSync(abs, args.content ?? '', 'utf8');
|
||||
return [{ type: 'text', text: `已写入 ${args.path}(${(args.content ?? '').length} 字节)` }];
|
||||
}
|
||||
case 'edit_file': {
|
||||
const abs = resolveSandboxed(args.path);
|
||||
const original = fs.readFileSync(abs, 'utf8');
|
||||
const oldStr = args.old_str ?? '';
|
||||
if (oldStr && !original.includes(oldStr)) {
|
||||
throw new Error('edit_file: old_str 在文件中不存在,替换失败');
|
||||
}
|
||||
const updated = oldStr ? original.replace(oldStr, args.new_str ?? '') : (args.new_str ?? '');
|
||||
fs.writeFileSync(abs, updated, 'utf8');
|
||||
return [{ type: 'text', text: `已编辑 ${args.path}` }];
|
||||
}
|
||||
case 'list_dir': {
|
||||
const target = args?.path ?? '.';
|
||||
const abs = resolveSandboxed(target);
|
||||
const entries = fs.readdirSync(abs, { withFileTypes: true });
|
||||
const lines = entries.map((e) => `${e.isDirectory() ? '[目录]' : '[文件]'} ${e.name}`);
|
||||
return [{ type: 'text', text: lines.join('\n') || '(空目录)' }];
|
||||
}
|
||||
case 'create_dir': {
|
||||
const abs = resolveSandboxed(args.path);
|
||||
fs.mkdirSync(abs, { recursive: true });
|
||||
return [{ type: 'text', text: `已创建目录 ${args.path}` }];
|
||||
}
|
||||
default:
|
||||
throw new Error(`未知工具:${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── JSON-RPC over stdio ────────────────────────────────────────────────────
|
||||
|
||||
function send(obj) {
|
||||
process.stdout.write(JSON.stringify(obj) + '\n');
|
||||
}
|
||||
|
||||
function respond(id, result) {
|
||||
send({ jsonrpc: '2.0', id, result });
|
||||
}
|
||||
|
||||
function respondError(id, message, code = -32603) {
|
||||
send({ jsonrpc: '2.0', id, error: { code, message } });
|
||||
}
|
||||
|
||||
const rl = readline.createInterface({ input: process.stdin, terminal: false });
|
||||
|
||||
rl.on('line', (raw) => {
|
||||
const line = raw.trim();
|
||||
if (!line) return;
|
||||
|
||||
let msg;
|
||||
try {
|
||||
msg = JSON.parse(line);
|
||||
} catch {
|
||||
process.stderr.write(`[mindspace-sandbox-mcp] invalid JSON: ${line}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { id, method, params } = msg;
|
||||
|
||||
switch (method) {
|
||||
case 'initialize':
|
||||
respond(id, {
|
||||
protocolVersion: '2024-11-05',
|
||||
serverInfo: { name: 'mindspace-sandbox', version: '1.0.0' },
|
||||
capabilities: { tools: {} },
|
||||
});
|
||||
break;
|
||||
|
||||
case 'initialized':
|
||||
// notification — no response
|
||||
break;
|
||||
|
||||
case 'tools/list':
|
||||
respond(id, { tools: TOOLS });
|
||||
break;
|
||||
|
||||
case 'tools/call': {
|
||||
const { name, arguments: toolArgs } = params ?? {};
|
||||
try {
|
||||
const content = callTool(name, toolArgs ?? {});
|
||||
respond(id, { content, isError: false });
|
||||
} catch (err) {
|
||||
respond(id, {
|
||||
content: [{ type: 'text', text: err.message }],
|
||||
isError: true,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
if (id !== undefined && id !== null) {
|
||||
respondError(id, `Method not found: ${method}`, -32601);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
rl.on('close', () => process.exit(0));
|
||||
Reference in New Issue
Block a user