diff --git a/mcp-servers/jest-server.js b/mcp-servers/jest-server.js deleted file mode 100644 index 35b54d5ae..000000000 --- a/mcp-servers/jest-server.js +++ /dev/null @@ -1,88 +0,0 @@ -#!/usr/bin/env node - -const { Server } = require('@modelcontextprotocol/sdk/server/index.js') -const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js') -const { CallToolRequestSchema, ListToolsRequestSchema } = require('@modelcontextprotocol/sdk/types.js') -const { spawn } = require('child_process') -const path = require('path') -const fs = require('fs') - -function resolveCwd(cwd) { - const root = process.cwd() - const full = path.resolve(root, cwd || '.') - const rel = path.relative(root, full) - if (rel.startsWith('..') || path.isAbsolute(rel)) throw new Error('cwd escapes workspace') - return full -} - -function binPath(bin) { - const local = path.join(process.cwd(), 'node_modules', '.bin', bin) - return fs.existsSync(local) ? local : bin -} - -function run(cmd, args, opt = {}) { - return new Promise((resolve) => { - const child = spawn(cmd, args, { cwd: opt.cwd, env: process.env, shell: false }) - let stdout = '' - let stderr = '' - child.stdout.on('data', d => { stdout += d.toString() }) - child.stderr.on('data', d => { stderr += d.toString() }) - child.on('close', code => resolve({ code, stdout, stderr })) - }) -} - -class JestServer { - constructor() { - this.server = new Server({ name: 'jest-server', version: '1.0.0' }, { capabilities: { tools: {} } }) - this.jest = binPath('jest') - this.setup() - } - - setup() { - this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ - tools: [ - { name: 'jest_run', description: 'Run Jest with optional pattern', inputSchema: { type: 'object', properties: { cwd: { type: 'string' }, testPathPattern: { type: 'string' }, coverage: { type: 'boolean', default: false }, updateSnapshots: { type: 'boolean', default: false }, ci: { type: 'boolean', default: true }, silent: { type: 'boolean', default: false } } } }, - { name: 'jest_file', description: 'Run a specific test file', inputSchema: { type: 'object', required: ['file'], properties: { cwd: { type: 'string' }, file: { type: 'string' }, updateSnapshots: { type: 'boolean', default: false } } } } - ] - })) - - this.server.setRequestHandler(CallToolRequestSchema, async (req) => { - const name = req.params.name - const a = req.params.arguments || {} - try { - const cwd = resolveCwd(a.cwd || '.') - switch (name) { - case 'jest_run': { - const args = [] - if (a.ci) args.push('--ci') - if (a.coverage) args.push('--coverage') - if (a.silent) args.push('--silent') - if (a.updateSnapshots) args.push('--updateSnapshot') - if (a.testPathPattern) args.push('--testPathPattern', a.testPathPattern) - const r = await run(this.jest, args, { cwd }) - return { content: [{ type: 'json', json: { code: r.code, stdout: r.stdout, stderr: r.stderr } }] } - } - case 'jest_file': { - const args = [a.file] - if (a.updateSnapshots) args.push('--updateSnapshot') - const r = await run(this.jest, args, { cwd }) - return { content: [{ type: 'json', json: { code: r.code, stdout: r.stdout, stderr: r.stderr } }] } - } - default: - return { content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true } - } - } catch (err) { - return { content: [{ type: 'text', text: `❌ ${name} error: ${err?.message || String(err)}` }], isError: true } - } - }) - } - - async run() { - const transport = new StdioServerTransport() - await this.server.connect(transport) - console.error('Jest MCP Server running on stdio') - } -} - -new JestServer().run() - diff --git a/mcp-servers/setup.md b/mcp-servers/setup.md index d7b58ae3f..dfab01cb5 100644 --- a/mcp-servers/setup.md +++ b/mcp-servers/setup.md @@ -57,9 +57,9 @@ Add to your Claude Desktop config file (`~/AppData/Roaming/Claude/claude_desktop "command": "node", "args": ["/path/to/orangecat/mcp-servers/lint-server.js"] }, - "jest": { + "vitest": { "command": "node", - "args": ["/path/to/orangecat/mcp-servers/jest-server.js"] + "args": ["/path/to/orangecat/mcp-servers/vitest-server.js"] }, "supabase": { "command": "node", @@ -83,7 +83,7 @@ node fs-server.js # Filesystem (workspace-restricted) node shell-server.js # Shell (allowlist) node git-server.js # Git node lint-server.js # ESLint/Prettier -node jest-server.js # Jest +node vitest-server.js # Vitest node supabase-server.js # Supabase ``` @@ -93,7 +93,7 @@ Once configured, you'll have access to these tools in Claude/Codex: - GitHub: repository/PRs/actions/secrets management - FS: read/write/list/move/delete files within workspace -- Shell: run allowlisted commands (npm, next, jest, eslint, prettier, rg) +- Shell: run allowlisted commands (npm, next, vitest, eslint, prettier, rg) - Git: status/diff/branch/checkout/add/commit/push - Lint: eslint check/fix, prettier check/write - Jest: run tests by pattern or file diff --git a/mcp-servers/shell-server.js b/mcp-servers/shell-server.js index a6e229a43..59ff72763 100644 --- a/mcp-servers/shell-server.js +++ b/mcp-servers/shell-server.js @@ -1,87 +1,139 @@ #!/usr/bin/env node +/* eslint-disable @typescript-eslint/no-require-imports -- plain CommonJS node + script (mcp-servers/ has its own package.json without "type":"module"); + lint-staged applies the app's TS rules here, where require() is correct. */ -const { Server } = require('@modelcontextprotocol/sdk/server/index.js') -const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js') -const { CallToolRequestSchema, ListToolsRequestSchema } = require('@modelcontextprotocol/sdk/types.js') -const { spawn } = require('child_process') -const path = require('path') +const { Server } = require('@modelcontextprotocol/sdk/server/index.js'); +const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js'); +const { + CallToolRequestSchema, + ListToolsRequestSchema, +} = require('@modelcontextprotocol/sdk/types.js'); +const { spawn } = require('child_process'); +const path = require('path'); -const ALLOWED = new Set(['npm','npx','node','next','jest','playwright','eslint','prettier','rg','echo']) +const ALLOWED = new Set([ + 'npm', + 'npx', + 'node', + 'next', + 'vitest', + 'playwright', + 'eslint', + 'prettier', + 'rg', + 'echo', +]); function resolveCwd(cwd) { - const root = process.cwd() - const full = path.resolve(root, cwd || '.') - const rel = path.relative(root, full) - if (rel.startsWith('..') || path.isAbsolute(rel)) throw new Error('cwd escapes workspace') - return full + const root = process.cwd(); + const full = path.resolve(root, cwd || '.'); + const rel = path.relative(root, full); + if (rel.startsWith('..') || path.isAbsolute(rel)) { + throw new Error('cwd escapes workspace'); + } + return full; } function runCommand(cmd, args, opt = {}) { - return new Promise((resolve) => { - const start = Date.now() - const child = spawn(cmd, args, { cwd: opt.cwd, env: process.env, shell: false }) - let stdout = '' - let stderr = '' - let finished = false + return new Promise(resolve => { + const start = Date.now(); + const child = spawn(cmd, args, { cwd: opt.cwd, env: process.env, shell: false }); + let stdout = ''; + let stderr = ''; + let finished = false; const killTimer = setTimeout(() => { if (!finished) { - finished = true - child.kill('SIGKILL') - resolve({ code: -1, stdout, stderr: stderr + `\nTimed out after ${opt.timeout}ms` }) + finished = true; + child.kill('SIGKILL'); + resolve({ code: -1, stdout, stderr: stderr + `\nTimed out after ${opt.timeout}ms` }); } - }, opt.timeout || 60000) - child.stdout.on('data', d => { stdout += d.toString() }) - child.stderr.on('data', d => { stderr += d.toString() }) + }, opt.timeout || 60000); + child.stdout.on('data', d => { + stdout += d.toString(); + }); + child.stderr.on('data', d => { + stderr += d.toString(); + }); child.on('close', code => { - if (finished) return - finished = true - clearTimeout(killTimer) - resolve({ code, stdout, stderr, ms: Date.now() - start }) - }) - }) + if (finished) { + return; + } + finished = true; + clearTimeout(killTimer); + resolve({ code, stdout, stderr, ms: Date.now() - start }); + }); + }); } class ShellServer { constructor() { - this.server = new Server({ name: 'shell-server', version: '1.0.0' }, { capabilities: { tools: {} } }) - this.setup() + this.server = new Server( + { name: 'shell-server', version: '1.0.0' }, + { capabilities: { tools: {} } } + ); + this.setup(); } setup() { this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ - { name: 'run', description: 'Run an allowed command with arguments', inputSchema: { type: 'object', required: ['cmd'], properties: { cmd: { type: 'string' }, args: { type: 'array', items: { type: ['string','number','boolean'] } }, cwd: { type: 'string' }, timeout: { type: 'number', default: 60000 } } } } - ] - })) + { + name: 'run', + description: 'Run an allowed command with arguments', + inputSchema: { + type: 'object', + required: ['cmd'], + properties: { + cmd: { type: 'string' }, + args: { type: 'array', items: { type: ['string', 'number', 'boolean'] } }, + cwd: { type: 'string' }, + timeout: { type: 'number', default: 60000 }, + }, + }, + }, + ], + })); - this.server.setRequestHandler(CallToolRequestSchema, async (req) => { - const name = req.params.name - const a = req.params.arguments || {} + this.server.setRequestHandler(CallToolRequestSchema, async req => { + const name = req.params.name; + const a = req.params.arguments || {}; try { switch (name) { case 'run': { - const program = String(a.cmd) - if (!ALLOWED.has(program)) throw new Error(`Command not allowed: ${program}`) - const cwd = resolveCwd(a.cwd || '.') - const args = Array.isArray(a.args) ? a.args.map(String) : [] - const res = await runCommand(program, args, { cwd, timeout: a.timeout || 60000 }) - return { content: [{ type: 'json', json: { code: res.code, stdout: res.stdout, stderr: res.stderr, ms: res.ms } }] } + const program = String(a.cmd); + if (!ALLOWED.has(program)) { + throw new Error(`Command not allowed: ${program}`); + } + const cwd = resolveCwd(a.cwd || '.'); + const args = Array.isArray(a.args) ? a.args.map(String) : []; + const res = await runCommand(program, args, { cwd, timeout: a.timeout || 60000 }); + return { + content: [ + { + type: 'json', + json: { code: res.code, stdout: res.stdout, stderr: res.stderr, ms: res.ms }, + }, + ], + }; } default: - return { content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true } + return { content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true }; } } catch (err) { - return { content: [{ type: 'text', text: `❌ ${name} error: ${err?.message || String(err)}` }], isError: true } + return { + content: [{ type: 'text', text: `❌ ${name} error: ${err?.message || String(err)}` }], + isError: true, + }; } - }) + }); } async run() { - const transport = new StdioServerTransport() - await this.server.connect(transport) - console.error('Shell MCP Server running on stdio') + const transport = new StdioServerTransport(); + await this.server.connect(transport); + console.error('Shell MCP Server running on stdio'); } } -new ShellServer().run() - +new ShellServer().run(); diff --git a/mcp-servers/vitest-server.js b/mcp-servers/vitest-server.js new file mode 100644 index 000000000..b67c77b63 --- /dev/null +++ b/mcp-servers/vitest-server.js @@ -0,0 +1,147 @@ +#!/usr/bin/env node +/* eslint-disable @typescript-eslint/no-require-imports -- plain CommonJS node + script (mcp-servers/ has its own package.json without "type":"module"); + lint-staged applies the app's TS rules here, where require() is correct. */ + +const { Server } = require('@modelcontextprotocol/sdk/server/index.js'); +const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js'); +const { + CallToolRequestSchema, + ListToolsRequestSchema, +} = require('@modelcontextprotocol/sdk/types.js'); +const { spawn } = require('child_process'); +const path = require('path'); +const fs = require('fs'); + +function resolveCwd(cwd) { + const root = process.cwd(); + const full = path.resolve(root, cwd || '.'); + const rel = path.relative(root, full); + if (rel.startsWith('..') || path.isAbsolute(rel)) { + throw new Error('cwd escapes workspace'); + } + return full; +} + +function binPath(bin) { + const local = path.join(process.cwd(), 'node_modules', '.bin', bin); + return fs.existsSync(local) ? local : bin; +} + +function run(cmd, args, opt = {}) { + return new Promise(resolve => { + const child = spawn(cmd, args, { cwd: opt.cwd, env: process.env, shell: false }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', d => { + stdout += d.toString(); + }); + child.stderr.on('data', d => { + stderr += d.toString(); + }); + child.on('close', code => resolve({ code, stdout, stderr })); + }); +} + +class JestServer { + constructor() { + this.server = new Server( + { name: 'vitest-server', version: '1.0.0' }, + { capabilities: { tools: {} } } + ); + this.vitest = binPath('vitest'); + this.setup(); + } + + setup() { + this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: 'vitest_run', + description: 'Run Vitest with an optional filename filter', + inputSchema: { + type: 'object', + properties: { + cwd: { type: 'string' }, + testPathPattern: { type: 'string' }, + coverage: { type: 'boolean', default: false }, + updateSnapshots: { type: 'boolean', default: false }, + silent: { type: 'boolean', default: false }, + }, + }, + }, + { + name: 'vitest_file', + description: 'Run a specific test file', + inputSchema: { + type: 'object', + required: ['file'], + properties: { + cwd: { type: 'string' }, + file: { type: 'string' }, + updateSnapshots: { type: 'boolean', default: false }, + }, + }, + }, + ], + })); + + this.server.setRequestHandler(CallToolRequestSchema, async req => { + const name = req.params.name; + const a = req.params.arguments || {}; + try { + const cwd = resolveCwd(a.cwd || '.'); + switch (name) { + case 'vitest_run': { + const args = ['run']; + if (a.coverage) { + args.push('--coverage'); + } + if (a.silent) { + args.push('--silent'); + } + if (a.updateSnapshots) { + args.push('--update'); + } + if (a.testPathPattern) { + args.push(a.testPathPattern); + } + const r = await run(this.vitest, args, { cwd }); + return { + content: [ + { type: 'json', json: { code: r.code, stdout: r.stdout, stderr: r.stderr } }, + ], + }; + } + case 'vitest_file': { + const args = ['run', a.file]; + if (a.updateSnapshots) { + args.push('--update'); + } + const r = await run(this.vitest, args, { cwd }); + return { + content: [ + { type: 'json', json: { code: r.code, stdout: r.stdout, stderr: r.stderr } }, + ], + }; + } + default: + return { content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true }; + } + } catch (err) { + return { + content: [{ type: 'text', text: `❌ ${name} error: ${err?.message || String(err)}` }], + isError: true, + }; + } + }); + } + + async run() { + const transport = new StdioServerTransport(); + await this.server.connect(transport); + console.error('Jest MCP Server running on stdio'); + } +} + +new JestServer().run();