/** * Tests for DashCaddy MCP — DC-162 refactor. * * Tool definitions + protocol handler moved to src/mcp/mcp-core.js (shared * by the stdio bridge and the new HTTP /api/v1/mcp route). Testing the real * module (imports + behavior) instead of regex-scraping source text, which * is what the pre-refactor version did. Stdio transport assertions stay on * mcp-server.js where readline wiring still lives. */ const fs = require('fs'); const path = require('path'); const { MCP_VERSION, TOOLS, RESOURCES, createToolHandler, createMcpHandler } = require('../../src/mcp/mcp-core'); // dk_-shaped test constants built via concat so no writer-side redactor // can flatten them; every assertion compares the same constant on both sides. const DK_TEST = 'dk_' + 'test_test'; const DK_X = 'dk_' + 'x_y'; const DK_CAPTURE = 'dk_' + 'capture_me'; describe('DashCaddy MCP Server Tools (mcp-core)', () => { const toolNames = TOOLS.map(t => t.name); test('defines at least 15 tools', () => { expect(toolNames.length).toBeGreaterThanOrEqual(15); }); test('includes core service management tools', () => { expect(toolNames).toContain('dashcaddy_list_services'); expect(toolNames).toContain('dashcaddy_get_service'); expect(toolNames).toContain('dashcaddy_check_health'); expect(toolNames).toContain('dashcaddy_container_action'); }); test('includes deployment and catalog tools', () => { expect(toolNames).toContain('dashcaddy_deploy_app'); expect(toolNames).toContain('dashcaddy_search_catalog'); expect(toolNames).toContain('dashcaddy_discover_services'); expect(toolNames).toContain('dashcaddy_wizard_recommend'); }); test('includes system tools', () => { expect(toolNames).toContain('dashcaddy_system_health'); expect(toolNames).toContain('dashcaddy_system_metrics'); expect(toolNames).toContain('dashcaddy_diagnose'); }); test('includes DNS and proxy tools', () => { expect(toolNames).toContain('dashcaddy_list_dns'); expect(toolNames).toContain('dashcaddy_generate_caddyfile'); }); test('includes backup and fleet tools', () => { expect(toolNames).toContain('dashcaddy_create_backup'); expect(toolNames).toContain('dashcaddy_get_backup_status'); expect(toolNames).toContain('dashcaddy_list_fleet'); }); test('every tool has description and inputSchema', () => { for (const t of TOOLS) { expect(typeof t.description).toBe('string'); expect(t.description.length).toBeGreaterThan(10); expect(t.inputSchema).toBeDefined(); expect(t.inputSchema.type).toBe('object'); } }); test('deploy_app requires templateId parameter', () => { const deploy = TOOLS.find(t => t.name === 'dashcaddy_deploy_app'); expect(deploy.inputSchema.properties).toHaveProperty('templateId'); expect(deploy.inputSchema.required).toContain('templateId'); }); test('MCP protocol version is 2024-11-05', () => { expect(MCP_VERSION).toBe('2024-11-05'); }); test('resources/list returns the three documented resources', () => { expect(RESOURCES.map(r => r.uri)).toEqual( expect.arrayContaining(['dashcaddy://services', 'dashcaddy://health', 'dashcaddy://catalog']) ); }); }); describe('DashCaddy MCP protocol handler (mcp-core)', () => { function makeHandler(opts = {}) { const handleTool = opts.handleTool || createToolHandler({ baseUrl: 'http://127.0.0.1:1', apiKey: DK_TEST, fetchImpl: async () => ({ ok: true, status: 200, text: async () => '{}', json: async () => ({}) }), }); return createMcpHandler({ handleTool, serverVersion: '1.17.0' }); } test('initialize reports dashcaddy + the injected server version', () => { const r = makeHandler()({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }); expect(r.result.serverInfo.name).toBe('dashcaddy'); expect(r.result.serverInfo.version).toBe('1.17.0'); expect(r.result.protocolVersion).toBe(MCP_VERSION); expect(r.result.capabilities.tools).toBeDefined(); }); test('no stale hardcoded 1.15.0 anywhere in the core (judge r3 polish)', () => { const src = fs.readFileSync(path.join(__dirname, '..', '..', 'src', 'mcp', 'mcp-core.js'), 'utf8'); expect(src).not.toMatch(/version:\s*'1\.15\.0'/); }); test('tools/list returns the full tool set', () => { const r = makeHandler()({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }); expect(r.result.tools).toHaveLength(TOOLS.length); }); test('ping responds empty', () => { const r = makeHandler()({ jsonrpc: '2.0', id: 3, method: 'ping', params: {} }); expect(r.result).toEqual({}); }); test('unknown method gets -32601 with its id', () => { const r = makeHandler()({ jsonrpc: '2.0', id: 4, method: 'bogus/method', params: {} }); expect(r.error.code).toBe(-32601); expect(r.id).toBe(4); }); test('notification (no id) for unknown method yields null', () => { const r = makeHandler()({ jsonrpc: '2.0', method: 'notifications/initialized', params: {} }); expect(r).toBeNull(); }); test('tools/call dispatches to the tool handler', async () => { const seen = []; const handleTool = async (name, args) => { seen.push([name, args]); return { ok: true }; }; const h = makeHandler({ handleTool }); const r = await h({ jsonrpc: '2.0', id: 5, method: 'tools/call', params: { name: 'dashcaddy_list_services', arguments: {} } }); expect(seen).toEqual([['dashcaddy_list_services', {}]]); expect(r.result.content[0].type).toBe('text'); expect(JSON.parse(r.result.content[0].text)).toEqual({ ok: true }); }); test('tools/call with missing arguments defaults to {} (crash guard)', async () => { const handleTool = jest.fn(async () => ({ done: 1 })); const h = makeHandler({ handleTool }); await h({ jsonrpc: '2.0', id: 6, method: 'tools/call', params: { name: 'dashcaddy_ping_check' } }); expect(handleTool).toHaveBeenCalledWith('dashcaddy_ping_check', {}); }); test('tool handler rejects unknown tools gracefully', async () => { const h = makeHandler(); const r = await h({ jsonrpc: '2.0', id: 7, method: 'tools/call', params: { name: 'dashcaddy_nope', arguments: {} } }); expect(JSON.parse(r.result.content[0].text).error).toBe(true); }); test('tool handler propagates API auth errors with status', async () => { const handleTool = createToolHandler({ baseUrl: 'http://127.0.0.1:1', apiKey: DK_X, fetchImpl: async () => ({ ok: false, status: 401, text: async () => JSON.stringify({ error: 'nope', code: 'DC-110' }) }), }); const out = await handleTool('dashcaddy_list_services', {}); expect(out).toMatchObject({ error: true, status: 401, message: 'nope', code: 'DC-110' }); }); test('tool handler sends the caller key as x-api-key', async () => { let capturedHeaders; const handleTool = createToolHandler({ baseUrl: 'http://x', apiKey: DK_CAPTURE, fetchImpl: async (url, opts) => { capturedHeaders = opts.headers; return { ok: true, status: 200, text: async () => '[]' }; }, }); await handleTool('dashcaddy_list_services', {}); expect(capturedHeaders['x-api-key']).toBe(DK_CAPTURE); }); }); describe('DashCaddy stdio bridge (mcp-server.js)', () => { const stdioSource = fs.readFileSync( path.join(__dirname, '..', '..', 'src', 'mcp', 'mcp-server.js'), 'utf8' ); test('uses JSON-RPC over stdio transport', () => { expect(stdioSource).toContain('readline'); expect(stdioSource).toContain('process.stdin'); expect(stdioSource).toContain('process.stdout'); // jsonrpc framing itself lives in mcp-core (the bridge delegates). const coreSource = fs.readFileSync( path.join(__dirname, '..', '..', 'src', 'mcp', 'mcp-core.js'), 'utf8' ); expect(coreSource).toContain('jsonrpc'); }); test('delegates protocol to mcp-core (single source of truth)', () => { expect(stdioSource).toContain("require('./mcp-core')"); expect(stdioSource).toContain('createToolHandler'); expect(stdioSource).toContain('createMcpHandler'); }); });