Skip to content
Open
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
125 changes: 99 additions & 26 deletions bun.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions packages/component-sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"node-pty": "^1.1.0"
},
"devDependencies": {
"@modelcontextprotocol/sdk": "^1.25.2",
"@types/har-format": "^1.2.16",
"@types/node": "^20.16.11",
"@typescript-eslint/eslint-plugin": "^8.53.0",
Expand Down
233 changes: 233 additions & 0 deletions packages/component-sdk/src/__tests__/tool-helpers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
import { describe, it, expect } from 'bun:test';
import { z } from 'zod';
import {
isAgentCallable,
inferBindingType,
getCredentialInputIds,
getActionInputIds,
getToolSchema,
getToolName,
getToolDescription,
getToolMetadata,
} from '../tool-helpers';
import { inputs, outputs, port } from '../schema-builders';
import { extractPorts } from '../zod-ports';
import type { ComponentDefinition, ComponentPortMetadata } from '../types';

// Helper to create a minimal component definition
function createComponent(
overrides: Partial<ComponentDefinition<any, any, any>> = {}
): ComponentDefinition<any, any, any> {
return {
id: 'test.component',
label: 'Test Component',
category: 'security',
runner: { kind: 'inline' },
inputs: inputs({}),
outputs: outputs({}),
docs: 'Test component documentation',
execute: async () => ({}),
...overrides,
};
}

describe('tool-helpers', () => {
describe('isAgentCallable', () => {
it('returns false when agentTool is not configured', () => {
const component = createComponent();
expect(isAgentCallable(component)).toBe(false);
});

it('returns false when agentTool.enabled is false', () => {
const component = createComponent({
ui: {
slug: 'test',
version: '1.0.0',
type: 'process',
category: 'security',
agentTool: { enabled: false },
},
});
expect(isAgentCallable(component)).toBe(false);
});

it('returns true when agentTool.enabled is true', () => {
const component = createComponent({
ui: {
slug: 'test',
version: '1.0.0',
type: 'process',
category: 'security',
agentTool: { enabled: true },
},
});
expect(isAgentCallable(component)).toBe(true);
});
});

describe('inferBindingType', () => {
it('returns explicit bindingType when set', () => {
const portWithExplicit: ComponentPortMetadata = {
id: 'test',
label: 'Test',
connectionType: { kind: 'primitive', name: 'text' },
bindingType: 'config',
};
expect(inferBindingType(portWithExplicit)).toBe('config');
});

it('infers credential for secret ports', () => {
const secretPort: ComponentPortMetadata = {
id: 'apiKey',
label: 'API Key',
connectionType: { kind: 'primitive', name: 'secret' },
};
expect(inferBindingType(secretPort)).toBe('credential');
});

it('infers credential for contract ports with credential flag', () => {
const contractPort: ComponentPortMetadata = {
id: 'awsCreds',
label: 'AWS Credentials',
connectionType: { kind: 'contract', name: 'aws', credential: true },
};
expect(inferBindingType(contractPort)).toBe('credential');
});

it('infers action for text ports', () => {
const textPort: ComponentPortMetadata = {
id: 'target',
label: 'Target',
connectionType: { kind: 'primitive', name: 'text' },
};
expect(inferBindingType(textPort)).toBe('action');
});

it('infers action for number ports', () => {
const numberPort: ComponentPortMetadata = {
id: 'count',
label: 'Count',
connectionType: { kind: 'primitive', name: 'number' },
};
expect(inferBindingType(numberPort)).toBe('action');
});
});

describe('getCredentialInputIds', () => {
it('returns IDs of credential inputs', () => {
const component = createComponent({
inputs: inputs({
apiKey: port(z.string(), { label: 'API Key', editor: 'secret' }),
target: port(z.string(), { label: 'Target' }),
awsCreds: port(z.any(), { label: 'AWS', isCredential: true, schemaName: 'aws', allowAny: true }),
}),
});
const credIds = getCredentialInputIds(component);
expect(credIds).toEqual(['apiKey', 'awsCreds']);
});
});

describe('getActionInputIds', () => {
it('returns IDs of action inputs', () => {
const component = createComponent({
inputs: inputs({
apiKey: port(z.string(), { label: 'API Key', editor: 'secret' }),
target: port(z.string(), { label: 'Target' }),
count: port(z.number(), { label: 'Count' }),
}),
});
expect(getActionInputIds(component)).toEqual(['target', 'count']);
});
});

describe('getToolSchema', () => {
it('returns schema with action inputs only', () => {
const component = createComponent({
inputs: inputs({
apiKey: port(z.string(), { label: 'API Key', editor: 'secret' }),
ipAddress: port(z.string(), { label: 'IP Address', description: 'IP to check' }),
verbose: port(z.boolean().default(false), { label: 'Verbose' }),
}),
});

const schema = getToolSchema(component);

expect(schema.type).toBe('object');
expect(Object.keys(schema.properties!)).toEqual(['ipAddress', 'verbose']);
expect(schema.properties!.ipAddress).toEqual({
type: 'string',
description: 'IP to check',
});
// Zod's toJSONSchema() correctly includes default values - this is better for MCP tools
expect(schema.properties!.verbose).toEqual({
type: 'boolean',
description: 'Verbose',
default: false,
});
// Note: Zod's toJSONSchema marks fields with defaults as required
// (the default is applied at runtime, not by JSON Schema)
expect(schema.required).toEqual(['ipAddress', 'verbose']);
});
});

describe('getToolName', () => {
it('uses agentTool.toolName when specified', () => {
const component = createComponent({
ui: {
slug: 'abuseipdb-lookup',
version: '1.0.0',
type: 'process',
category: 'security',
agentTool: {
enabled: true,
toolName: 'check_ip_reputation',
},
},
});
expect(getToolName(component)).toBe('check_ip_reputation');
});

it('derives from slug when toolName not specified', () => {
const component = createComponent({
ui: {
slug: 'abuseipdb-lookup',
version: '1.0.0',
type: 'process',
category: 'security',
agentTool: { enabled: true },
},
});
expect(getToolName(component)).toBe('abuseipdb_lookup');
});
});

describe('getToolMetadata', () => {
it('returns complete tool metadata for MCP', () => {
const component = createComponent({
ui: {
slug: 'abuseipdb-lookup',
version: '1.0.0',
type: 'process',
category: 'security',
description: 'Look up IP reputation',
agentTool: {
enabled: true,
toolName: 'check_ip_reputation',
toolDescription: 'Check if an IP address is malicious',
},
},
inputs: inputs({
apiKey: port(z.string(), { label: 'API Key', editor: 'secret' }),
ipAddress: port(z.string(), { label: 'IP Address' }),
}),
});

const metadata = getToolMetadata(component);

expect(metadata.name).toBe('check_ip_reputation');
expect(metadata.description).toBe('Check if an IP address is malicious');
expect(metadata.inputSchema.properties).toHaveProperty('ipAddress');
expect(metadata.inputSchema.properties).not.toHaveProperty('apiKey');
});
});
});
1 change: 1 addition & 0 deletions packages/component-sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export * from './registry';
export * from './context';
export * from './runner';
export * from './errors';
export * from './tool-helpers';
export * from './http/types';
export * from './http/har-builder';
export * from './http/instrumented-fetch';
Expand Down
2 changes: 1 addition & 1 deletion packages/component-sdk/src/json-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ function getObjectShape(schema: z.ZodTypeAny): Record<string, z.ZodTypeAny> {
* @param schema - Zod schema to convert
* @returns JSON Schema object
*/
export function getToolSchema(schema: z.ZodTypeAny): Record<string, unknown> {
export function generateJsonSchema(schema: z.ZodTypeAny): Record<string, unknown> {
const jsonSchema = zodToJsonSchema(schema);

return jsonSchema;
Expand Down
Loading