diff --git a/server/ftchat/services/tool-registry.js b/server/ftchat/services/tool-registry.js new file mode 100644 index 0000000..27ac8d4 --- /dev/null +++ b/server/ftchat/services/tool-registry.js @@ -0,0 +1,181 @@ +/** + * Tool registry: Notion read/write + code repo read/write + * + * Defines tools the model can call during conversation. + * Tool call format in model output: + * <> + * + * Tools connect to MCP endpoints via connector-proxy. + */ +'use strict'; + +const https = require('https'); +const http = require('http'); + +// MCP proxy configuration +const MCP_PROXY = process.env.MCP_PROXY_URL || 'http://localhost:3010'; + +/** + * Parse a tool call from model output + * Format: <> + */ +function parseToolCall(text) { + if (!text) return null; + const match = text.match(/<]+)>>/); + if (!match) return null; + + const toolName = match[1]; + const argsStr = match[2]; + const args = {}; + + // Parse key="value" pairs + const argRegex = /(\w+)="([^"]*)"/g; + let argMatch; + while ((argMatch = argRegex.exec(argsStr)) !== null) { + args[argMatch[1]] = argMatch[2]; + } + + return { tool: toolName, args }; +} + +/** + * Execute a parsed tool call + */ +async function executeToolCall(toolCall) { + if (!toolCall || !toolCall.tool) { + throw new Error('Invalid tool call'); + } + + switch (toolCall.tool) { + case 'read_notion': + return await readNotion(toolCall.args); + case 'write_notion': + return await writeNotion(toolCall.args); + case 'read_repo': + return await readRepo(toolCall.args); + case 'write_repo': + return await writeRepo(toolCall.args); + case 'search_repo': + return await searchRepo(toolCall.args); + default: + throw new Error('Unknown tool: ' + toolCall.tool); + } +} + +/** + * List available tools (for model guidance) + */ +function listTools() { + return [ + { + name: 'read_notion', + description: 'Read content from a Notion page', + params: { page_id: 'string - The Notion page ID to read' } + }, + { + name: 'write_notion', + description: 'Append content to a Notion page', + params: { page_id: 'string - The Notion page ID', content: 'string - Content to append' } + }, + { + name: 'read_repo', + description: 'Read a file from the code repository', + params: { path: 'string - File path in repo' } + }, + { + name: 'write_repo', + description: 'Write a file to the code repository', + params: { path: 'string - File path', content: 'string - File content' } + }, + { + name: 'search_repo', + description: 'Search code in the repository', + params: { query: 'string - Search keyword' } + } + ]; +} + +/** + * Build a tool usage guide string for the model + */ +function buildToolGuide() { + const tools = listTools(); + let guide = 'Available tools (use <> to call):\n'; + for (const t of tools) { + guide += `- ${t.name}: ${t.description}\n`; + for (const [k, v] of Object.entries(t.params)) { + guide += ` ${k}: ${v}\n`; + } + } + return guide; +} + +// ─── Tool Implementations ─── + +function mcpRequest(method, path, body) { + const url = new URL(MCP_PROXY); + const transport = url.protocol === 'https:' ? https : http; + + return new Promise((resolve, reject) => { + const options = { + hostname: url.hostname, + port: url.port || 80, + path: path, + method: method, + headers: { 'Content-Type': 'application/json' } + }; + + const req = transport.request(options, (res) => { + let data = ''; + res.on('data', chunk => data += chunk); + res.on('end', () => { + try { resolve(JSON.parse(data)); } + catch (e) { resolve({ raw: data }); } + }); + }); + + req.on('error', reject); + if (body) req.write(JSON.stringify(body)); + req.end(); + }); +} + +async function readNotion(args) { + const pageId = args.page_id || args.pageId; + if (!pageId) throw new Error('read_notion requires page_id'); + // Use the connector-proxy MCP endpoint for Notion + return await mcpRequest('POST', '/api/mcp/notion/read', { page_id: pageId }); +} + +async function writeNotion(args) { + const pageId = args.page_id || args.pageId; + const content = args.content; + if (!pageId || !content) throw new Error('write_notion requires page_id and content'); + return await mcpRequest('POST', '/api/mcp/notion/append', { page_id: pageId, content }); +} + +async function readRepo(args) { + const path = args.path; + if (!path) throw new Error('read_repo requires path'); + return await mcpRequest('POST', '/api/mcp/repo/read', { path }); +} + +async function writeRepo(args) { + const path = args.path; + const content = args.content; + if (!path || !content) throw new Error('write_repo requires path and content'); + return await mcpRequest('POST', '/api/mcp/repo/write', { path, content }); +} + +async function searchRepo(args) { + const query = args.query; + if (!query) throw new Error('search_repo requires query'); + return await mcpRequest('POST', '/api/mcp/repo/search', { query }); +} + +module.exports = { + parseToolCall, + executeToolCall, + listTools, + buildToolGuide +};