|
| 1 | +'use strict'; |
| 2 | + |
| 3 | +const shortuuid = require('short-uuid'); |
| 4 | +const uuid = require('uuid'); |
| 5 | + |
| 6 | +const TOOLS_OUTPUT_POLL_TIMEOUT = 2 * 60 * 1000; // 120 seconds |
| 7 | +const TOOLS_OUTPUT_POLL_INTERVAL = 300; // 300ms |
| 8 | + |
| 9 | +module.exports = { |
| 10 | + |
| 11 | + start: async function(context) { |
| 12 | + |
| 13 | + const tools = await this.collectTools(context); |
| 14 | + await context.service.stateAddToSet(`user:${context.userId}`, { |
| 15 | + flowId: context.flowId, |
| 16 | + componentId: context.componentId, |
| 17 | + tools, |
| 18 | + webhook: context.getWebhookUrl() |
| 19 | + }); |
| 20 | + return context.callAppmixer({ |
| 21 | + endPoint: '/plugins/appmixer/ai/mcptools/gateways', |
| 22 | + method: 'POST', |
| 23 | + body: {} |
| 24 | + }); |
| 25 | + }, |
| 26 | + |
| 27 | + stop: async function(context) { |
| 28 | + |
| 29 | + const tools = await context.stateGet('tools'); |
| 30 | + await context.service.stateRemoveFromSet(`user:${context.userId}`, { |
| 31 | + flowId: context.flowId, |
| 32 | + componentId: context.componentId, |
| 33 | + tools, |
| 34 | + webhook: context.getWebhookUrl() |
| 35 | + }); |
| 36 | + return context.callAppmixer({ |
| 37 | + endPoint: '/plugins/appmixer/ai/mcptools/gateways/' + context.componentId, |
| 38 | + method: 'DELETE' |
| 39 | + }); |
| 40 | + }, |
| 41 | + |
| 42 | + collectTools: async function(context) { |
| 43 | + |
| 44 | + const tools = await this.getAllToolsDefinition(context); |
| 45 | + await context.log({ step: 'tools', tools }); |
| 46 | + await context.stateSet('tools', tools); |
| 47 | + return tools; |
| 48 | + }, |
| 49 | + |
| 50 | + getAllToolsDefinition: async function(context) { |
| 51 | + |
| 52 | + const flowDescriptor = context.flowDescriptor; |
| 53 | + const agentComponentId = context.componentId; |
| 54 | + const toolsPort = 'tools'; |
| 55 | + |
| 56 | + // Create a new assistant with tools defined in the branches connected to my 'tools' output port. |
| 57 | + const tools = {}; |
| 58 | + let error; |
| 59 | + |
| 60 | + // Find all components connected to my 'tools' output port. |
| 61 | + Object.keys(flowDescriptor).forEach((componentId) => { |
| 62 | + const component = flowDescriptor[componentId]; |
| 63 | + const sources = component.source; |
| 64 | + Object.keys(sources || {}).forEach((inPort) => { |
| 65 | + const source = sources[inPort]; |
| 66 | + if (source[agentComponentId] && source[agentComponentId].includes(toolsPort)) { |
| 67 | + tools[componentId] = component; |
| 68 | + if (component.type !== 'appmixer.ai.agenttools.ToolStart') { |
| 69 | + error = `Component ${componentId} is not of type 'ToolStart' but ${component.type}. |
| 70 | + Every tool chain connected to the '${toolsPort}' port of the AI Agent |
| 71 | + must start with 'ToolStart' and end with 'ToolOutput'. |
| 72 | + This is where you describe what the tool does and what parameters should the AI model provide to it.`; |
| 73 | + } |
| 74 | + } |
| 75 | + }); |
| 76 | + }); |
| 77 | + |
| 78 | + // Teach the user via logs that they need to use the 'ToolStart' component. |
| 79 | + if (error) { |
| 80 | + throw new context.CancelError(error); |
| 81 | + } |
| 82 | + |
| 83 | + const toolsDefinition = this.getToolsDefinition(tools); |
| 84 | + const mcpToolsDefinition = await this.getMCPToolsDefinition(context); |
| 85 | + return toolsDefinition.concat(mcpToolsDefinition); |
| 86 | + }, |
| 87 | + |
| 88 | + mcpListTools: function(context, componentId) { |
| 89 | + |
| 90 | + return context.callAppmixer({ |
| 91 | + endPoint: `/flows/${context.flowId}/components/${componentId}?action=listTools`, |
| 92 | + method: 'POST', |
| 93 | + body: {} |
| 94 | + }); |
| 95 | + }, |
| 96 | + |
| 97 | + mcpCallTool: function(context, componentId, toolName, args) { |
| 98 | + |
| 99 | + return context.callAppmixer({ |
| 100 | + endPoint: `/flows/${context.flowId}/components/${componentId}?action=callTool`, |
| 101 | + method: 'POST', |
| 102 | + body: { |
| 103 | + name: toolName, |
| 104 | + arguments: args |
| 105 | + } |
| 106 | + }); |
| 107 | + }, |
| 108 | + |
| 109 | + isMCPserver: function(context, componentId) { |
| 110 | + // Check if the component is an MCP server. |
| 111 | + const component = context.flowDescriptor[componentId]; |
| 112 | + if (!component) { |
| 113 | + return false; |
| 114 | + } |
| 115 | + const category = component.type.split('.').slice(0, 2).join('.'); |
| 116 | + const type = component.type.split('.').at(-1); |
| 117 | + if (category === 'appmixer.mcpservers' && type === 'MCPServer') { |
| 118 | + return true; |
| 119 | + } |
| 120 | + return false; |
| 121 | + }, |
| 122 | + |
| 123 | + getMCPToolsDefinition: async function(context) { |
| 124 | + |
| 125 | + // https://platform.openai.com/docs/assistants/tools/function-calling |
| 126 | + const toolsDefinition = []; |
| 127 | + |
| 128 | + const flowDescriptor = context.flowDescriptor; |
| 129 | + const agentComponentId = context.componentId; |
| 130 | + const mcpPort = 'mcp'; |
| 131 | + const components = {}; |
| 132 | + let error; |
| 133 | + // Find all components connected to my 'mcp' output port. |
| 134 | + Object.keys(flowDescriptor).forEach((componentId) => { |
| 135 | + const component = flowDescriptor[componentId]; |
| 136 | + const sources = component.source; |
| 137 | + Object.keys(sources || {}).forEach((inPort) => { |
| 138 | + const source = sources[inPort]; |
| 139 | + if (source[agentComponentId] && source[agentComponentId].includes(mcpPort)) { |
| 140 | + components[componentId] = component; |
| 141 | + if (component.type.split('.').slice(0, 2).join('.') !== 'appmixer.mcpservers') { |
| 142 | + error = `Component ${componentId} is not an 'MCP Server' but ${component.type}. |
| 143 | + Every mcp component connected to the '${mcpPort}' port of the AI Agent |
| 144 | + must be an MCP server.`; |
| 145 | + } |
| 146 | + } |
| 147 | + }); |
| 148 | + }); |
| 149 | + |
| 150 | + // Teach the user via logs that they need to connect only MCP servers to the mcp port. |
| 151 | + if (error) { |
| 152 | + throw new context.CancelError(error); |
| 153 | + } |
| 154 | + |
| 155 | + for (const componentId in components) { |
| 156 | + // For each 'MCP Server' component, call the component to retrieve available tools. |
| 157 | + const component = components[componentId]; |
| 158 | + const tools = await this.mcpListTools(context, componentId); |
| 159 | + await context.log({ step: 'mcp-server-list-tools', componentId, component, tools }); |
| 160 | + |
| 161 | + for (const tool of tools) { |
| 162 | + // Note we convert the UUID component ID to a shorter version |
| 163 | + // to avoid exceeding the 64 characters limit of the function name. |
| 164 | + const name = [shortuuid().fromUUID(componentId), tool.name].join('_'); |
| 165 | + const toolDefinition = { |
| 166 | + type: 'function', |
| 167 | + function: { |
| 168 | + name, |
| 169 | + description: tool.description |
| 170 | + } |
| 171 | + }; |
| 172 | + if (tool.inputSchema) { |
| 173 | + toolDefinition.function.parameters = tool.inputSchema; |
| 174 | + } |
| 175 | + if (toolDefinition.function.parameters && toolDefinition.function.parameters.type === 'object' && !toolDefinition.function.parameters.properties) { |
| 176 | + toolDefinition.function.parameters.properties = {}; |
| 177 | + } |
| 178 | + toolsDefinition.push(toolDefinition); |
| 179 | + } |
| 180 | + } |
| 181 | + |
| 182 | + return toolsDefinition; |
| 183 | + }, |
| 184 | + |
| 185 | + getToolsDefinition: function(tools) { |
| 186 | + |
| 187 | + // https://platform.openai.com/docs/assistants/tools/function-calling |
| 188 | + const toolsDefinition = []; |
| 189 | + |
| 190 | + Object.keys(tools).forEach((componentId) => { |
| 191 | + const component = tools[componentId]; |
| 192 | + const parameters = component.config.properties.parameters?.ADD || []; |
| 193 | + const toolParameters = { |
| 194 | + type: 'object', |
| 195 | + properties: {} |
| 196 | + }; |
| 197 | + parameters.forEach((parameter) => { |
| 198 | + // Skip empty objects |
| 199 | + if (Object.keys(parameter).length === 0) { |
| 200 | + return; |
| 201 | + } |
| 202 | + toolParameters.properties[parameter.name] = { |
| 203 | + type: parameter.type, |
| 204 | + description: parameter.description |
| 205 | + }; |
| 206 | + }); |
| 207 | + let toolName = (component.label || component.type.split('.').pop()); |
| 208 | + toolName = toolName.replace(/[^a-zA-Z0-9_]/g, '_').slice(0, 64 - componentId.length - 1); |
| 209 | + const toolDefinition = { |
| 210 | + type: 'function', |
| 211 | + function: { |
| 212 | + name: componentId + '_' + toolName, |
| 213 | + description: component.config.properties.description |
| 214 | + } |
| 215 | + }; |
| 216 | + if (parameters.length) { |
| 217 | + toolDefinition.function.parameters = toolParameters; |
| 218 | + } |
| 219 | + toolsDefinition.push(toolDefinition); |
| 220 | + }); |
| 221 | + return toolsDefinition; |
| 222 | + }, |
| 223 | + |
| 224 | + callTool: async function(context, componentId, args) { |
| 225 | + |
| 226 | + const toolCall = { |
| 227 | + componentId, |
| 228 | + args, |
| 229 | + id: context.messages.webhook.correlationId |
| 230 | + }; |
| 231 | + const toolCalls = [toolCall]; |
| 232 | + // Send to all tools. Each ai.ToolStart ignores tool calls that are not intended for it. |
| 233 | + await context.sendJson({ toolCalls }, 'tools'); |
| 234 | + // Output of each tool is expected to be stored in the service state |
| 235 | + // under the ID of the tool call. This is done in the ToolStartOutput component. |
| 236 | + // Collect outputs of all the required tool calls. |
| 237 | + await context.log({ step: 'collect-tools-output', toolCalls }); |
| 238 | + |
| 239 | + const pollStart = Date.now(); |
| 240 | + const pollTimeout = context.config.TOOLS_OUTPUT_POLL_TIMEOUT || TOOLS_OUTPUT_POLL_TIMEOUT; |
| 241 | + const pollInterval = context.config.TOOLS_OUTPUT_POLL_INTERVAL || TOOLS_OUTPUT_POLL_INTERVAL; |
| 242 | + while ((Date.now() - pollStart) < pollTimeout) { |
| 243 | + const result = await context.flow.stateGet(toolCall.id); |
| 244 | + if (result) { |
| 245 | + await context.flow.stateUnset(toolCall.id); |
| 246 | + return result.output; |
| 247 | + } |
| 248 | + // Sleep. |
| 249 | + await new Promise((resolve) => setTimeout(resolve, pollInterval)); |
| 250 | + } |
| 251 | + return 'Error: Tool timed out.'; |
| 252 | + }, |
| 253 | + |
| 254 | + receive: async function(context) { |
| 255 | + |
| 256 | + if (context.messages.webhook) { |
| 257 | + |
| 258 | + const req = context.messages.webhook.content; |
| 259 | + let componentId = req.data.function.name.split('_')[0]; |
| 260 | + const toolName = req.data.function.name.split('_').slice(1).join('_'); |
| 261 | + const args = typeof req.data.function.arguments === 'string' ? JSON.parse(req.data.function.arguments) : req.data.function.arguments; |
| 262 | + if (!uuid.validate(componentId)) { |
| 263 | + // Short version of the UUID. |
| 264 | + // Get back the original compoennt UUID back from the short version. |
| 265 | + componentId = shortuuid().toUUID(componentId); |
| 266 | + } |
| 267 | + if (this.isMCPserver(context, componentId)) { |
| 268 | + // MCP Server. Get output directly. |
| 269 | + let output; |
| 270 | + // Catch errors so that we don't trigger an Appmixer component retry. |
| 271 | + // Simply return the error message instead. |
| 272 | + try { |
| 273 | + output = await this.mcpCallTool( |
| 274 | + context, |
| 275 | + componentId, |
| 276 | + toolName, |
| 277 | + args |
| 278 | + ); |
| 279 | + } catch (err) { |
| 280 | + await context.log({ step: 'call-tool-error', componentId, toolName, err }); |
| 281 | + output = `Error calling tool ${toolName}: ${err.message}`; |
| 282 | + } |
| 283 | + output = typeof output === 'string' ? output : JSON.stringify(output, null, 2); |
| 284 | + return context.response(output, 200); |
| 285 | + } else { |
| 286 | + let output = await this.callTool(context, componentId, args); |
| 287 | + output = typeof output === 'string' ? output : JSON.stringify(output, null, 2); |
| 288 | + return context.response(output, 200); |
| 289 | + } |
| 290 | + } |
| 291 | + } |
| 292 | +}; |
0 commit comments