Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
220 changes: 209 additions & 11 deletions src/commands/start.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ const {
lastTransportInstance,
mockLoadBricks,
mockReadFile,
mockGetBricks,
mockGetStatus,
mockGetBrick,
mockSetStatus,
mockUnregister,
} = vi.hoisted(() => {
const mockListen = vi.fn();
const mockOnce = vi.fn();
Expand Down Expand Up @@ -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(),
};
});

Expand All @@ -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,
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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: {} },
},
]);
Expand All @@ -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;
});
Expand All @@ -255,9 +284,9 @@ describe('startCommand', () => {
params: { name: string; arguments?: Record<string, unknown> };
}) => 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' }],
});
Expand Down Expand Up @@ -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<string, unknown> };
}) => 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<string, unknown> };
}) => 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<string, unknown> };
}) => 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<string, unknown> };
}) => 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<string, unknown> };
}) => 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<string, unknown> };
}) => 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;
});
});
});
107 changes: 102 additions & 5 deletions src/commands/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,15 +75,112 @@ export async function startCommand(argv: string[] = []): Promise<void> {
);

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,
},
},
],
Comment thread
samuelds marked this conversation as resolved.
}));

// 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<string, unknown>)?.['name'];
if (typeof brickName !== 'string' || brickName.trim() === '') {
return {
content: [{ type: 'text' as const, text: 'Missing or invalid brick name.' }],
isError: true,
};
}
Comment thread
samuelds marked this conversation as resolved.
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 {
Expand Down
Loading