Skip to content

Commit 477b440

Browse files
committed
ai.mcptools (new) MCPGateway component to connect appmixer-mcp server
1 parent 89e0cdd commit 477b440

File tree

10 files changed

+578
-0
lines changed

10 files changed

+578
-0
lines changed
Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
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+
};
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"name": "appmixer.ai.mcptools.MCPGateway",
3+
"author": "Appmixer <info@appmixer.com>",
4+
"description": "Define your MCP Server tools that connect to the Appmixer MCP Server which you can install to your LLM clients such as Claude.",
5+
"outPorts": [{
6+
"name": "out"
7+
}, {
8+
"name": "tools",
9+
"options": [{
10+
"label": "Prompt",
11+
"value": "prompt",
12+
"schema": { "type": "string" }
13+
}]
14+
}, {
15+
"name": "mcp"
16+
}],
17+
"icon": "data:image/png;base64,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"
18+
}
Lines changed: 15 additions & 0 deletions
Loading
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"name": "appmixer.ai.mcptools",
3+
"version": "1.0.0",
4+
"changelog": {
5+
"1.0.0": [
6+
"First version."
7+
]
8+
}
9+
}

src/appmixer/ai/mcptools/icon.png

3.81 KB
Loading

0 commit comments

Comments
 (0)