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
123 changes: 122 additions & 1 deletion src/cli/commands/__tests__/sh.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'bun:test';
import { slugify, parseInlineArgs, resolveArgs } from '../sh';
import { slugify, parseInlineArgs, resolveArgs, coerceValue } from '../sh';
import type { ShellCommand } from '../sh';

function makeCommand(overrides: Partial<ShellCommand> = {}): ShellCommand {
Expand Down Expand Up @@ -107,6 +107,127 @@ describe('resolveArgs', () => {
});
});

describe('coerceValue', () => {
it('should coerce number type', () => {
expect(coerceValue('42', { type: 'number' })).toBe(42);
expect(coerceValue('3.14', { type: 'number' })).toBe(3.14);
expect(coerceValue('-10', { type: 'number' })).toBe(-10);
});

it('should coerce integer type', () => {
expect(coerceValue('42', { type: 'integer' })).toBe(42);
expect(coerceValue('0', { type: 'integer' })).toBe(0);
});

it('should return original string for non-numeric number type', () => {
expect(coerceValue('abc', { type: 'number' })).toBe('abc');
});

it('should coerce boolean type', () => {
expect(coerceValue('true', { type: 'boolean' })).toBe(true);
expect(coerceValue('false', { type: 'boolean' })).toBe(false);
expect(coerceValue('0', { type: 'boolean' })).toBe(false);
expect(coerceValue('1', { type: 'boolean' })).toBe(true);
});

it('should coerce array of strings from comma-separated', () => {
expect(coerceValue('a,b,c', { type: 'array', items: { type: 'string' } }))
.toEqual(['a', 'b', 'c']);
});

it('should trim whitespace in comma-separated arrays', () => {
expect(coerceValue('a, b, c', { type: 'array', items: { type: 'string' } }))
.toEqual(['a', 'b', 'c']);
});

it('should coerce array of numbers from comma-separated', () => {
expect(coerceValue('1,2,3', { type: 'array', items: { type: 'number' } }))
.toEqual([1, 2, 3]);
});

it('should coerce array of integers from comma-separated', () => {
expect(coerceValue('10,20,30', { type: 'array', items: { type: 'integer' } }))
.toEqual([10, 20, 30]);
});

it('should coerce array from JSON syntax', () => {
expect(coerceValue('[1,2,3]', { type: 'array', items: { type: 'number' } }))
.toEqual([1, 2, 3]);
});

it('should coerce array of objects from JSON syntax', () => {
expect(coerceValue('[{"a":1},{"b":2}]', { type: 'array', items: { type: 'object' } }))
.toEqual([{ a: 1 }, { b: 2 }]);
});

it('should coerce array without items schema as strings', () => {
expect(coerceValue('x,y,z', { type: 'array' }))
.toEqual(['x', 'y', 'z']);
});

it('should coerce object type via JSON parse', () => {
expect(coerceValue('{"key":"value"}', { type: 'object' }))
.toEqual({ key: 'value' });
});

it('should return raw string for invalid object JSON', () => {
expect(coerceValue('not-json', { type: 'object' })).toBe('not-json');
});

it('should keep string type as-is', () => {
expect(coerceValue('hello', { type: 'string' })).toBe('hello');
expect(coerceValue('42', { type: 'string' })).toBe('42');
});

it('should infer types when no schema is provided', () => {
expect(coerceValue('hello', undefined)).toBe('hello');
});

it('should infer types when schema has no type field', () => {
expect(coerceValue('true', {})).toBe(true);
expect(coerceValue('false', {})).toBe(false);
expect(coerceValue('42', {})).toBe(42);
expect(coerceValue('hello', {})).toBe('hello');
expect(coerceValue('{"a":1}', {})).toEqual({ a: 1 });
expect(coerceValue('[1,2]', {})).toEqual([1, 2]);
});
});

describe('resolveArgs with type coercion', () => {
it('should coerce integer parameters', () => {
const cmd = makeCommand({
argSlugs: new Map([['limit', 'limit']]),
inputSchema: {
type: 'object',
properties: { limit: { type: 'integer' } },
},
});
expect(resolveArgs(cmd, { limit: '5' })).toEqual({ limit: 5 });
});

it('should coerce array parameters from comma-separated values', () => {
const cmd = makeCommand({
argSlugs: new Map([['tags', 'tags']]),
inputSchema: {
type: 'object',
properties: { tags: { type: 'array', items: { type: 'string' } } },
},
});
expect(resolveArgs(cmd, { tags: 'a,b,c' })).toEqual({ tags: ['a', 'b', 'c'] });
});

it('should coerce object parameters from JSON strings', () => {
const cmd = makeCommand({
argSlugs: new Map([['config', 'config']]),
inputSchema: {
type: 'object',
properties: { config: { type: 'object' } },
},
});
expect(resolveArgs(cmd, { config: '{"key":"val"}' })).toEqual({ config: { key: 'val' } });
});
});

describe('slugify', () => {
it('should convert camelCase to kebab-case', () => {
expect(slugify('pageSize')).toBe('page-size');
Expand Down
66 changes: 59 additions & 7 deletions src/cli/commands/sh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,19 +174,71 @@ export function parseInlineArgs(tokens: string[]): Record<string, string> {
return result;
}

/**
* Coerce a raw string value to the type declared in the JSON Schema property.
* Handles: string, number, integer, boolean, array, object, and
* multi-type schemas (e.g. oneOf / type:[...]).
*/
export function coerceValue(value: string, schema: any): any {
if (!schema) return value;

const type = schema.type;

if (type === 'number' || type === 'integer') {
const n = Number(value);
return Number.isNaN(n) ? value : n;
}

if (type === 'boolean') {
return value !== 'false' && value !== '0';
}

if (type === 'array') {
// Try JSON parse first for complex arrays (e.g. '[1,2,3]' or '[{"a":1}]')
if (value.startsWith('[')) {
try { return JSON.parse(value); } catch {}
}
// Comma-separated fallback
const items = value.split(',').map(s => s.trim());
const itemType = schema.items?.type;
if (itemType === 'number' || itemType === 'integer') {
return items.map(s => { const n = Number(s); return Number.isNaN(n) ? s : n; });
}
if (itemType === 'boolean') {
return items.map(s => s !== 'false' && s !== '0');
}
if (itemType === 'object') {
return items.map(s => { try { return JSON.parse(s); } catch { return s; } });
}
return items;
}

if (type === 'object') {
try { return JSON.parse(value); } catch { return value; }
}

if (type === 'string') {
return value;
}

// No type or unknown type — try to infer from the value
if (value === 'true') return true;
if (value === 'false') return false;
if (value.startsWith('{') || value.startsWith('[')) {
try { return JSON.parse(value); } catch {}
}
const n = Number(value);
if (!Number.isNaN(n) && value.trim() !== '') return n;
return value;
}

/** Resolve slugified arg names in the user's input to original names expected by the tool. */
export function resolveArgs(cmd: ShellCommand, rawArgs: Record<string, string>): Record<string, any> {
const resolved: Record<string, any> = {};
for (const [slug, value] of Object.entries(rawArgs)) {
const originalName = cmd.argSlugs.get(slug) ?? slug;
const propSchema = cmd.inputSchema?.properties?.[originalName];
if (propSchema?.type === 'number') {
resolved[originalName] = Number(value);
} else if (propSchema?.type === 'boolean') {
resolved[originalName] = value !== 'false' && value !== '0';
} else {
resolved[originalName] = value;
}
resolved[originalName] = coerceValue(value, propSchema);
}
return resolved;
}
Expand Down
85 changes: 29 additions & 56 deletions src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,23 @@ class CapaServer {
}
}

private getOrCreateMCPServer(projectId: string): CapaMCPServer | null {
let mcpServer = this.mcpServers.get(projectId);
if (mcpServer) return mcpServer;

const project = this.db.getProject(projectId);
if (!project) return null;

mcpServer = new CapaMCPServer(
this.db,
this.sessionManager,
projectId,
project.path
);
this.mcpServers.set(projectId, mcpServer);
return mcpServer;
}

private async handleGetServerTools(projectId: string, serverId: string): Promise<Response> {
const apiLogger = this.logger.child('API');
apiLogger.info(`List tools for server: ${serverId} in project: ${projectId}`);
Expand All @@ -479,21 +496,11 @@ class CapaServer {
);
}

let mcpServer = this.mcpServers.get(projectId);
const mcpServer = this.getOrCreateMCPServer(projectId);
if (!mcpServer) {
const project = this.db.getProject(projectId);
if (!project) {
return new Response(
JSON.stringify({ error: 'Project not found' }),
{ status: 404, headers: { 'Content-Type': 'application/json' } }
);
}
mcpServer = new CapaMCPServer(
this.db,
this.sessionManager,
this.subprocessManager,
projectId,
project.path
return new Response(
JSON.stringify({ error: 'Project not found' }),
{ status: 404, headers: { 'Content-Type': 'application/json' } }
);
}

Expand Down Expand Up @@ -524,23 +531,12 @@ class CapaServer {
);
}

let mcpServer = this.mcpServers.get(projectId);
const mcpServer = this.getOrCreateMCPServer(projectId);
if (!mcpServer) {
const project = this.db.getProject(projectId);
if (!project) {
return new Response(
JSON.stringify({ error: 'Project not found' }),
{ status: 404, headers: { 'Content-Type': 'application/json' } }
);
}
mcpServer = new CapaMCPServer(
this.db,
this.sessionManager,
this.subprocessManager,
projectId,
project.path
return new Response(
JSON.stringify({ error: 'Project not found' }),
{ status: 404, headers: { 'Content-Type': 'application/json' } }
);
this.mcpServers.set(projectId, mcpServer);
}

const tools: ShellToolInfo[] = await mcpServer.getAllShellTools(capabilities);
Expand Down Expand Up @@ -575,16 +571,9 @@ class CapaServer {
const pluginServers = capabilities.servers.filter((s) => s.sourcePlugin);
if (pluginServers.length > 0) {
try {
const project = this.db.getProject(projectId);
if (project) {
const tempMcpServer = new CapaMCPServer(
this.db,
this.sessionManager,
this.subprocessManager,
projectId,
project.path
);
capabilitiesToUse = await tempMcpServer.enrichCapabilitiesWithPluginTools(capabilities);
const mcpServer = this.getOrCreateMCPServer(projectId);
if (mcpServer) {
capabilitiesToUse = await mcpServer.enrichCapabilitiesWithPluginTools(capabilities);
this.sessionManager.setProjectCapabilities(projectId, capabilitiesToUse);
}
} catch (error: any) {
Expand Down Expand Up @@ -673,23 +662,9 @@ class CapaServer {
apiLogger.info('Validating tools...');
let toolValidationResults: any[] = [];
try {
// Create a temporary MCP server instance for validation
const mcpServer = this.mcpServers.get(projectId);
const mcpServer = this.getOrCreateMCPServer(projectId);
if (mcpServer) {
toolValidationResults = await mcpServer.validateTools(capabilitiesToUse);
} else {
// Create temporary instance just for validation
const project = this.db.getProject(projectId);
if (project) {
const tempMcpServer = new CapaMCPServer(
this.db,
this.sessionManager,
this.subprocessManager,
projectId,
project.path
);
toolValidationResults = await tempMcpServer.validateTools(capabilitiesToUse);
}
}

// Filter out validation failures for OAuth2 servers that need connection
Expand Down Expand Up @@ -1479,11 +1454,9 @@ class CapaServer {
return new Response('Project not found', { status: 404 });
}

// Create MCP server
mcpServer = new CapaMCPServer(
this.db,
this.sessionManager,
this.subprocessManager,
projectId,
project.path
);
Expand Down
7 changes: 2 additions & 5 deletions src/server/mcp-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import { SessionManager } from './session-manager';
import type { SessionInfo } from './session-manager';
import { CommandToolExecutor } from './tool-executor';
import { MCPProxy } from './mcp-proxy';
import { SubprocessManager } from './subprocess-manager';
import { VERSION } from '../version';
import { logger } from '../shared/logger';

Expand Down Expand Up @@ -74,7 +73,6 @@ export class CapaMCPServer {
private server: Server;
private db: CapaDatabase;
private sessionManager: SessionManager;
private subprocessManager: SubprocessManager;
private mcpProxy: MCPProxy;
private projectId: string;
private projectPath: string;
Expand All @@ -85,16 +83,14 @@ export class CapaMCPServer {
constructor(
db: CapaDatabase,
sessionManager: SessionManager,
subprocessManager: SubprocessManager,
projectId: string,
projectPath: string
) {
this.db = db;
this.sessionManager = sessionManager;
this.subprocessManager = subprocessManager;
this.projectId = projectId;
this.projectPath = projectPath;
this.mcpProxy = new MCPProxy(db, projectId, projectPath, subprocessManager);
this.mcpProxy = new MCPProxy(db, projectId, projectPath);

this.server = new Server(
{
Expand Down Expand Up @@ -1207,6 +1203,7 @@ export class CapaMCPServer {
}

async close(): Promise<void> {
await this.mcpProxy.closeAll();
await this.server.close();
}
}
Loading
Loading