diff --git a/packages/component-sdk/src/__tests__/tool-helpers.test.ts b/packages/component-sdk/src/__tests__/tool-helpers.test.ts new file mode 100644 index 00000000..12fdc39d --- /dev/null +++ b/packages/component-sdk/src/__tests__/tool-helpers.test.ts @@ -0,0 +1,245 @@ +import { describe, it, expect } from 'bun:test'; +import { z } from 'zod'; +import { + isAgentCallable, + inferBindingType, + getCredentialInputIds, + getActionInputIds, + getToolSchema, + getToolName, + getToolDescription, + getToolMetadata, +} from '../tool-helpers'; +import { port } from '../ports'; +import type { ComponentDefinition, ComponentPortMetadata } from '../types'; + +// Helper to create a minimal component definition +function createComponent( + overrides: Partial = {} +): ComponentDefinition { + return { + id: 'test.component', + label: 'Test Component', + category: 'security', + runner: { kind: 'inline' }, + inputSchema: z.object({}), + outputSchema: z.object({}), + 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({ + metadata: { + 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({ + metadata: { + 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', + dataType: port.text(), + bindingType: 'config', + }; + expect(inferBindingType(portWithExplicit)).toBe('config'); + }); + + it('infers credential for secret ports', () => { + const secretPort: ComponentPortMetadata = { + id: 'apiKey', + label: 'API Key', + dataType: port.secret(), + }; + expect(inferBindingType(secretPort)).toBe('credential'); + }); + + it('infers credential for contract ports with credential flag', () => { + const contractPort: ComponentPortMetadata = { + id: 'awsCreds', + label: 'AWS Credentials', + dataType: port.credential('aws'), + }; + expect(inferBindingType(contractPort)).toBe('credential'); + }); + + it('infers action for text ports', () => { + const textPort: ComponentPortMetadata = { + id: 'target', + label: 'Target', + dataType: port.text(), + }; + expect(inferBindingType(textPort)).toBe('action'); + }); + + it('infers action for number ports', () => { + const numberPort: ComponentPortMetadata = { + id: 'count', + label: 'Count', + dataType: port.number(), + }; + expect(inferBindingType(numberPort)).toBe('action'); + }); + }); + + describe('getCredentialInputIds', () => { + it('returns IDs of credential inputs', () => { + const component = createComponent({ + metadata: { + slug: 'test', + version: '1.0.0', + type: 'process', + category: 'security', + inputs: [ + { id: 'apiKey', label: 'API Key', dataType: port.secret() }, + { id: 'target', label: 'Target', dataType: port.text() }, + { id: 'awsCreds', label: 'AWS', dataType: port.credential('aws') }, + ], + }, + }); + expect(getCredentialInputIds(component)).toEqual(['apiKey', 'awsCreds']); + }); + }); + + describe('getActionInputIds', () => { + it('returns IDs of action inputs', () => { + const component = createComponent({ + metadata: { + slug: 'test', + version: '1.0.0', + type: 'process', + category: 'security', + inputs: [ + { id: 'apiKey', label: 'API Key', dataType: port.secret() }, + { id: 'target', label: 'Target', dataType: port.text() }, + { id: 'count', label: 'Count', dataType: port.number() }, + ], + }, + }); + expect(getActionInputIds(component)).toEqual(['target', 'count']); + }); + }); + + describe('getToolSchema', () => { + it('returns schema with action inputs only', () => { + const component = createComponent({ + metadata: { + slug: 'test', + version: '1.0.0', + type: 'process', + category: 'security', + inputs: [ + { id: 'apiKey', label: 'API Key', dataType: port.secret() }, + { id: 'ipAddress', label: 'IP Address', dataType: port.text(), required: true, description: 'IP to check' }, + { id: 'verbose', label: 'Verbose', dataType: port.boolean() }, + ], + }, + }); + + 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', + }); + expect(schema.properties.verbose).toEqual({ + type: 'boolean', + description: 'Verbose', + }); + expect(schema.required).toEqual(['ipAddress']); + }); + }); + + describe('getToolName', () => { + it('uses agentTool.toolName when specified', () => { + const component = createComponent({ + metadata: { + 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({ + metadata: { + 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({ + metadata: { + 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: [ + { id: 'apiKey', label: 'API Key', dataType: port.secret() }, + { id: 'ipAddress', label: 'IP Address', dataType: port.text(), required: true }, + ], + }, + }); + + 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'); + }); + }); +}); diff --git a/packages/component-sdk/src/index.ts b/packages/component-sdk/src/index.ts index cc921127..c43d090b 100644 --- a/packages/component-sdk/src/index.ts +++ b/packages/component-sdk/src/index.ts @@ -18,6 +18,7 @@ export * from './runner'; export * from './ports'; export * from './contracts'; export * from './errors'; +export * from './tool-helpers'; export * from './http/types'; export * from './http/har-builder'; export * from './http/instrumented-fetch'; diff --git a/packages/component-sdk/src/tool-helpers.ts b/packages/component-sdk/src/tool-helpers.ts new file mode 100644 index 00000000..a2cdcd8b --- /dev/null +++ b/packages/component-sdk/src/tool-helpers.ts @@ -0,0 +1,195 @@ +/** + * Helper functions for working with agent-callable components (tool mode). + */ + +import type { + ComponentDefinition, + ComponentPortMetadata, + PortBindingType, + AgentToolConfig, +} from './types'; + +/** + * JSON Schema type for MCP tool input schema + */ +export interface ToolInputSchema { + type: 'object'; + properties: Record; + required: string[]; +} + +/** + * Metadata for an agent-callable tool, suitable for MCP tools/list response + */ +export interface ToolMetadata { + name: string; + description: string; + inputSchema: ToolInputSchema; +} + +/** + * Check if a component is configured as an agent-callable tool. + */ +export function isAgentCallable(component: ComponentDefinition): boolean { + return component.metadata?.agentTool?.enabled === true; +} + +/** + * Infer the binding type for a port based on its data type. + * - secret, contract with credential flag → 'credential' + * - everything else → 'action' + */ +export function inferBindingType(port: ComponentPortMetadata): PortBindingType { + // Explicit binding type takes precedence + if (port.bindingType) { + return port.bindingType; + } + + const dataType = port.dataType; + + // Secret ports are always credentials + if (dataType.kind === 'primitive' && dataType.name === 'secret') { + return 'credential'; + } + + // Contract ports with credential flag are credentials + if (dataType.kind === 'contract' && dataType.credential) { + return 'credential'; + } + + // Everything else is an action input + return 'action'; +} + +/** + * Get the IDs of all credential inputs for a component. + * These are inputs that should be pre-bound from the workflow, not exposed to the agent. + */ +export function getCredentialInputIds(component: ComponentDefinition): string[] { + const inputs = component.metadata?.inputs ?? []; + return inputs + .filter(input => inferBindingType(input) === 'credential') + .map(input => input.id); +} + +/** + * Get the IDs of all action inputs for a component. + * These are inputs that the agent provides at runtime. + */ +export function getActionInputIds(component: ComponentDefinition): string[] { + const inputs = component.metadata?.inputs ?? []; + return inputs + .filter(input => inferBindingType(input) === 'action') + .map(input => input.id); +} + +/** + * Convert a port data type to a JSON Schema type string. + */ +function portTypeToJsonSchemaType(port: ComponentPortMetadata): string { + const dataType = port.dataType; + + if (dataType.kind === 'primitive') { + switch (dataType.name) { + case 'text': + case 'secret': + return 'string'; + case 'number': + return 'number'; + case 'boolean': + return 'boolean'; + case 'json': + case 'any': + return 'object'; + case 'file': + return 'string'; // File path or URL + default: + return 'string'; + } + } + + if (dataType.kind === 'list') { + return 'array'; + } + + if (dataType.kind === 'map') { + return 'object'; + } + + if (dataType.kind === 'contract') { + return 'object'; + } + + return 'string'; +} + +/** + * Get the JSON Schema for the action inputs only (inputs exposed to the agent). + * This is used for the MCP tools/list inputSchema field. + */ +export function getToolSchema(component: ComponentDefinition): ToolInputSchema { + const inputs = component.metadata?.inputs ?? []; + const actionInputs = inputs.filter(input => inferBindingType(input) === 'action'); + + const properties: ToolInputSchema['properties'] = {}; + const required: string[] = []; + + for (const input of actionInputs) { + properties[input.id] = { + type: portTypeToJsonSchemaType(input), + description: input.description ?? input.label, + }; + + if (input.required) { + required.push(input.id); + } + } + + return { + type: 'object', + properties, + required, + }; +} + +/** + * Get the tool name for a component. + * Uses agentTool.toolName if specified, otherwise derives from component slug. + */ +export function getToolName(component: ComponentDefinition): string { + if (component.metadata?.agentTool?.toolName) { + return component.metadata.agentTool.toolName; + } + + // Derive from slug: 'abuseipdb-lookup' → 'abuseipdb_lookup' + const slug = component.metadata?.slug ?? component.id; + return slug.replace(/-/g, '_').replace(/\./g, '_'); +} + +/** + * Get the tool description for a component. + * Uses agentTool.toolDescription if specified, otherwise uses component docs/description. + */ +export function getToolDescription(component: ComponentDefinition): string { + if (component.metadata?.agentTool?.toolDescription) { + return component.metadata.agentTool.toolDescription; + } + + return component.metadata?.description ?? component.docs ?? component.label; +} + +/** + * Get complete tool metadata for MCP tools/list response. + */ +export function getToolMetadata(component: ComponentDefinition): ToolMetadata { + return { + name: getToolName(component), + description: getToolDescription(component), + inputSchema: getToolSchema(component), + }; +} diff --git a/packages/component-sdk/src/types.ts b/packages/component-sdk/src/types.ts index 857c618c..33518087 100644 --- a/packages/component-sdk/src/types.ts +++ b/packages/component-sdk/src/types.ts @@ -149,6 +149,14 @@ export type PortDataType = | MapPortType | ContractPortType; +/** + * Binding type for tool mode - determines how inputs are handled when component is used as an agent tool. + * - 'credential': Pre-bound from workflow (API keys, tokens) - never exposed to agent + * - 'action': Provided by agent at runtime (the actual work to do) + * - 'config': Pre-configured settings that don't change per invocation + */ +export type PortBindingType = 'credential' | 'action' | 'config'; + export interface ComponentPortMetadata { id: string; label: string; @@ -160,6 +168,15 @@ export interface ComponentPortMetadata { isBranching?: boolean; /** Custom color for branching ports: 'green' | 'red' | 'amber' | 'blue' | 'purple' | 'slate' */ branchColor?: 'green' | 'red' | 'amber' | 'blue' | 'purple' | 'slate'; + /** + * Binding type for tool mode. Determines how this input is handled when the component + * is used as an agent-callable tool. + * - 'credential': Pre-bound from workflow, never exposed to agent + * - 'action': Provided by agent at runtime + * - 'config': Pre-configured, doesn't change per invocation + * @default Inferred from dataType: secret/contract → 'credential', others → 'action' + */ + bindingType?: PortBindingType; } export type ComponentParameterType = @@ -224,6 +241,25 @@ export type ComponentUiType = | 'process' | 'output'; +/** + * Configuration for exposing a component as an agent-callable tool. + */ +export interface AgentToolConfig { + /** Whether this component can be used as an agent tool */ + enabled: boolean; + /** + * Tool name exposed to the agent. Defaults to component slug with underscores. + * Should be descriptive and follow snake_case convention. + * @example 'check_ip_reputation', 'query_cloudtrail' + */ + toolName?: string; + /** + * Description of what the tool does, shown to the agent. + * Should clearly explain the tool's purpose and when to use it. + */ + toolDescription?: string; +} + export interface ComponentUiMetadata { slug: string; version: string; @@ -244,6 +280,12 @@ export interface ComponentUiMetadata { examples?: string[]; /** UI-only component - should not be included in workflow execution */ uiOnly?: boolean; + /** + * Configuration for exposing this component as an agent-callable tool. + * When enabled, the component can be used in tool mode within workflows, + * allowing AI agents to invoke it via the MCP gateway. + */ + agentTool?: AgentToolConfig; } export interface ExecutionContext {