From d73bbc912be60b3104cde50fb04d2a71af9f0e5f Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 20 Apr 2026 17:24:22 +0200 Subject: [PATCH] feat: expose focus_list, focus_load, focus_unload as MCP tools Allow LLMs to inspect and manage loaded bricks dynamically: - focus_list: returns loaded bricks and their tools - focus_load: stub (pending core dynamic brick API) - focus_unload: stops brick, removes from registry Co-Authored-By: Claude Opus 4.6 (1M context) --- src/commands/start.test.ts | 220 +++++++++++++++++++++++++++++++++++-- src/commands/start.ts | 107 +++++++++++++++++- 2 files changed, 311 insertions(+), 16 deletions(-) diff --git a/src/commands/start.test.ts b/src/commands/start.test.ts index f5f5b4c..01f1193 100644 --- a/src/commands/start.test.ts +++ b/src/commands/start.test.ts @@ -19,6 +19,11 @@ const { lastTransportInstance, mockLoadBricks, mockReadFile, + mockGetBricks, + mockGetStatus, + mockGetBrick, + mockSetStatus, + mockUnregister, } = vi.hoisted(() => { const mockListen = vi.fn(); const mockOnce = vi.fn(); @@ -49,6 +54,11 @@ const { lastTransportInstance, mockLoadBricks, mockReadFile, + mockGetBricks: vi.fn().mockReturnValue([]), + mockGetStatus: vi.fn().mockReturnValue('running'), + mockGetBrick: vi.fn().mockReturnValue(undefined), + mockSetStatus: vi.fn(), + mockUnregister: vi.fn(), }; }); @@ -57,7 +67,13 @@ vi.mock('@focusmcp/core', () => ({ start: mockStart, stop: mockStop, router: { listTools: mockListTools, callTool: mockCallTool }, - registry: {}, + registry: { + getBricks: mockGetBricks, + getStatus: mockGetStatus, + getBrick: mockGetBrick, + setStatus: mockSetStatus, + unregister: mockUnregister, + }, bus: {}, }), loadBricks: mockLoadBricks, @@ -132,6 +148,14 @@ describe('startCommand', () => { mockReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); mockLoadBricks.mockReset(); mockLoadBricks.mockResolvedValue({ bricks: [], failures: [] }); + mockGetBricks.mockReset(); + mockGetBricks.mockReturnValue([]); + mockGetStatus.mockReset(); + mockGetStatus.mockReturnValue('running'); + mockGetBrick.mockReset(); + mockGetBrick.mockReturnValue(undefined); + mockSetStatus.mockReset(); + mockUnregister.mockReset(); }); afterEach(() => { @@ -203,8 +227,8 @@ describe('startCommand', () => { it('ListTools handler returns mapped tools from router', async () => { mockListTools.mockReturnValue([ { - name: 'focus_list', - description: 'Lists bricks', + name: 'echo_say', + description: 'Says something', inputSchema: { type: 'object', properties: {} }, }, ]); @@ -223,15 +247,20 @@ describe('startCommand', () => { const handler = listToolsCall[1] as () => Promise<{ tools: unknown[] }>; const result = await handler(); - expect(result).toEqual({ - tools: [ + // Should include the brick tool + 3 internal tools + expect(result.tools).toEqual( + expect.arrayContaining([ { - name: 'focus_list', - description: 'Lists bricks', + name: 'echo_say', + description: 'Says something', inputSchema: { type: 'object', properties: {} }, }, - ], - }); + expect.objectContaining({ name: 'focus_list' }), + expect.objectContaining({ name: 'focus_load' }), + expect.objectContaining({ name: 'focus_unload' }), + ]), + ); + expect((result.tools as unknown[]).length).toBe(4); void promise; }); @@ -255,9 +284,9 @@ describe('startCommand', () => { params: { name: string; arguments?: Record }; }) => Promise<{ content: unknown[] }>; - const result = await handler({ params: { name: 'focus_list', arguments: { foo: 'bar' } } }); + const result = await handler({ params: { name: 'echo_say', arguments: { foo: 'bar' } } }); - expect(mockCallTool).toHaveBeenCalledWith('focus_list', { foo: 'bar' }); + expect(mockCallTool).toHaveBeenCalledWith('echo_say', { foo: 'bar' }); expect(result).toEqual({ content: [{ type: 'text', text: 'hello' }], }); @@ -523,4 +552,173 @@ describe('startCommand', () => { void promise; }); + + describe('internal tools', () => { + it('focus_list returns "No bricks loaded." when registry is empty', async () => { + mockGetBricks.mockReturnValue([]); + + const { startCommand } = await import('./start.ts'); + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + const callToolCall = mockSetRequestHandler.mock.calls.find( + (call) => call[0] === 'CallToolRequestSchema', + ); + if (!callToolCall) throw new Error('CallTool handler not registered'); + const handler = callToolCall[1] as (req: { + params: { name: string; arguments?: Record }; + }) => Promise<{ content: unknown[] }>; + + const result = await handler({ params: { name: 'focus_list', arguments: {} } }); + + expect(result).toEqual({ + content: [{ type: 'text', text: 'No bricks loaded.' }], + }); + expect(mockCallTool).not.toHaveBeenCalled(); + + void promise; + }); + + it('focus_list returns brick names, statuses and tools when bricks are loaded', async () => { + mockGetBricks.mockReturnValue([ + { + manifest: { + name: 'echo', + tools: [{ name: 'echo_say', description: 'Say something' }], + }, + start: vi.fn(), + stop: vi.fn(), + }, + ]); + mockGetStatus.mockReturnValue('running'); + + const { startCommand } = await import('./start.ts'); + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + const callToolCall = mockSetRequestHandler.mock.calls.find( + (call) => call[0] === 'CallToolRequestSchema', + ); + if (!callToolCall) throw new Error('CallTool handler not registered'); + const handler = callToolCall[1] as (req: { + params: { name: string; arguments?: Record }; + }) => Promise<{ content: Array<{ type: string; text: string }> }>; + + const result = await handler({ params: { name: 'focus_list', arguments: {} } }); + + expect(result.content[0]?.type).toBe('text'); + expect(result.content[0]?.text).toContain('echo'); + expect(result.content[0]?.text).toContain('running'); + expect(result.content[0]?.text).toContain('echo_say'); + expect(mockCallTool).not.toHaveBeenCalled(); + + void promise; + }); + + it('focus_load returns stub message', async () => { + const { startCommand } = await import('./start.ts'); + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + const callToolCall = mockSetRequestHandler.mock.calls.find( + (call) => call[0] === 'CallToolRequestSchema', + ); + if (!callToolCall) throw new Error('CallTool handler not registered'); + const handler = callToolCall[1] as (req: { + params: { name: string; arguments?: Record }; + }) => Promise<{ content: Array<{ type: string; text: string }> }>; + + const result = await handler({ + params: { name: 'focus_load', arguments: { name: 'echo' } }, + }); + + expect(result.content[0]?.type).toBe('text'); + expect(result.content[0]?.text).toContain('not yet implemented'); + expect(mockCallTool).not.toHaveBeenCalled(); + + void promise; + }); + + it('focus_unload returns error when brick not found', async () => { + mockGetBrick.mockReturnValue(undefined); + + const { startCommand } = await import('./start.ts'); + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + const callToolCall = mockSetRequestHandler.mock.calls.find( + (call) => call[0] === 'CallToolRequestSchema', + ); + if (!callToolCall) throw new Error('CallTool handler not registered'); + const handler = callToolCall[1] as (req: { + params: { name: string; arguments?: Record }; + }) => Promise<{ content: Array<{ type: string; text: string }>; isError?: boolean }>; + + const result = await handler({ + params: { name: 'focus_unload', arguments: { name: 'unknown-brick' } }, + }); + + expect(result.isError).toBe(true); + expect(result.content[0]?.text).toContain('not found'); + expect(mockCallTool).not.toHaveBeenCalled(); + + void promise; + }); + + it('focus_unload stops and unregisters the brick when found', async () => { + const mockBrickStop = vi.fn().mockResolvedValue(undefined); + const fakeBrick = { + manifest: { name: 'echo', tools: [] }, + start: vi.fn(), + stop: mockBrickStop, + }; + mockGetBrick.mockReturnValue(fakeBrick); + + const { startCommand } = await import('./start.ts'); + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + const callToolCall = mockSetRequestHandler.mock.calls.find( + (call) => call[0] === 'CallToolRequestSchema', + ); + if (!callToolCall) throw new Error('CallTool handler not registered'); + const handler = callToolCall[1] as (req: { + params: { name: string; arguments?: Record }; + }) => Promise<{ content: Array<{ type: string; text: string }>; isError?: boolean }>; + + const result = await handler({ + params: { name: 'focus_unload', arguments: { name: 'echo' } }, + }); + + expect(result.isError).toBeUndefined(); + expect(mockBrickStop).toHaveBeenCalledOnce(); + expect(mockSetStatus).toHaveBeenCalledWith('echo', 'stopped'); + expect(mockUnregister).toHaveBeenCalledWith('echo'); + expect(result.content[0]?.text).toContain('unloaded successfully'); + expect(mockCallTool).not.toHaveBeenCalled(); + + void promise; + }); + + it('focus_unload returns isError when brick name is missing', async () => { + const { startCommand } = await import('./start.ts'); + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + const callToolCall = mockSetRequestHandler.mock.calls.find( + (call) => call[0] === 'CallToolRequestSchema', + ); + if (!callToolCall) throw new Error('CallTool handler not registered'); + const handler = callToolCall[1] as (req: { + params: { name: string; arguments?: Record }; + }) => Promise<{ content: Array<{ type: string; text: string }>; isError?: boolean }>; + + const result = await handler({ params: { name: 'focus_unload', arguments: {} } }); + + expect(result.isError).toBe(true); + expect(result.content[0]?.text).toContain('Missing or invalid brick name'); + + void promise; + }); + }); }); diff --git a/src/commands/start.ts b/src/commands/start.ts index c99ecf0..508406c 100644 --- a/src/commands/start.ts +++ b/src/commands/start.ts @@ -75,15 +75,112 @@ export async function startCommand(argv: string[] = []): Promise { ); server.setRequestHandler(ListToolsRequestSchema, async () => ({ - tools: focusMcp.router.listTools().map((t) => ({ - name: t.name, - description: t.description, - inputSchema: t.inputSchema, - })), + tools: [ + ...focusMcp.router.listTools().map((t) => ({ + name: t.name, + description: t.description, + inputSchema: t.inputSchema, + })), + { + name: 'focus_list', + description: 'List all loaded bricks and their tools', + inputSchema: { type: 'object', properties: {}, additionalProperties: false }, + }, + { + name: 'focus_load', + description: + 'Load (activate) an installed brick — its tools become available immediately', + inputSchema: { + type: 'object', + properties: { name: { type: 'string', description: 'Brick name to load' } }, + required: ['name'], + additionalProperties: false, + }, + }, + { + name: 'focus_unload', + description: + 'Unload (deactivate) a running brick — its tools are removed immediately', + inputSchema: { + type: 'object', + properties: { name: { type: 'string', description: 'Brick name to unload' } }, + required: ['name'], + additionalProperties: false, + }, + }, + ], })); + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: internal tool dispatch with multiple branches server.setRequestHandler(CallToolRequestSchema, async (req) => { const { name, arguments: args } = req.params; + + // Internal tools — handled before dispatching to brick router + if (name === 'focus_list') { + const bricks = focusMcp.registry.getBricks(); + if (bricks.length === 0) { + return { content: [{ type: 'text' as const, text: 'No bricks loaded.' }] }; + } + const lines = bricks.map((b) => { + const status = focusMcp.registry.getStatus(b.manifest.name); + const toolNames = b.manifest.tools.map((t) => t.name).join(', ') || '(no tools)'; + return `- ${b.manifest.name} [${status}]: ${toolNames}`; + }); + return { content: [{ type: 'text' as const, text: lines.join('\n') }] }; + } + + if (name === 'focus_load') { + return { + content: [ + { + type: 'text' as const, + text: 'Load not yet implemented. Use focus start with center.json to load bricks at startup.', + }, + ], + }; + } + + if (name === 'focus_unload') { + const brickName = (args as Record)?.['name']; + if (typeof brickName !== 'string' || brickName.trim() === '') { + return { + content: [{ type: 'text' as const, text: 'Missing or invalid brick name.' }], + isError: true, + }; + } + const brick = focusMcp.registry.getBrick(brickName); + if (!brick) { + return { + content: [{ type: 'text' as const, text: `Brick "${brickName}" not found.` }], + isError: true, + }; + } + try { + await brick.stop(); + focusMcp.registry.setStatus(brickName, 'stopped'); + focusMcp.registry.unregister(brickName); + return { + content: [ + { + type: 'text' as const, + text: `Brick "${brickName}" unloaded successfully.`, + }, + ], + }; + } catch (err) { + return { + content: [ + { + type: 'text' as const, + text: `Failed to unload "${brickName}": ${err instanceof Error ? err.message : String(err)}`, + }, + ], + isError: true, + }; + } + } + + // Brick tools (existing dispatch) try { const result = await focusMcp.router.callTool(name, args ?? {}); return {