diff --git a/src/appmixer/asanamcp/auth.js b/src/appmixer/asanamcp/auth.js new file mode 100644 index 0000000000..5dbd137d73 --- /dev/null +++ b/src/appmixer/asanamcp/auth.js @@ -0,0 +1,116 @@ +'use strict'; + +/** + * OAuth 2.1 + PKCE authentication for Asana MCP server. + * + * Asana MCP uses a separate "MCP app" type created in the Asana developer console. + * Tokens issued for MCP apps only work with the MCP server — they cannot be used + * with the standard Asana API. + * + * Key differences from standard Asana OAuth: + * - App type must be "MCP app" in developer console + * - The `resource` parameter is included in the auth URL to specify MCP server + * - No scopes are needed (MCP apps don't use scopes) + * - PKCE with S256 is required + * + * @see https://developers.asana.com/docs/integrating-with-asanas-mcp-server + */ +module.exports = { + + type: 'oauth2', + + definition: () => { + + let profileInfo; + + return { + + accountNameFromProfileInfo: context => { + + return context.profileInfo?.email + || context.profileInfo?.name + || context.profileInfo?.gid + || 'Asana MCP User'; + }, + + // MCP apps don't use scopes + scope: [], + + authUrl(context) { + + return 'https://app.asana.com/-/oauth_authorize?' + + `client_id=${encodeURIComponent(context.clientId)}&` + + `redirect_uri=${encodeURIComponent(context.callbackUrl)}&` + + 'response_type=code&' + + `resource=${encodeURIComponent('https://mcp.asana.com/v2')}&` + + `state=${encodeURIComponent(context.ticket)}`; + }, + + async requestAccessToken(context) { + + const tokenUrl = 'https://app.asana.com/-/oauth_token?' + + 'grant_type=authorization_code&' + + `code=${context.authorizationCode}&` + + `redirect_uri=${encodeURIComponent(context.callbackUrl)}&` + + `client_id=${context.clientId}&` + + `client_secret=${context.clientSecret}`; + + const { data: result } = await context.httpRequest({ + method: 'POST', + url: tokenUrl, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' } + }); + + profileInfo = result.data; + + const newDate = new Date(); + newDate.setTime(newDate.getTime() + (result.expires_in * 1000)); + + return { + accessToken: result.access_token, + refreshToken: result.refresh_token, + accessTokenExpDate: newDate + }; + }, + + requestProfileInfo: () => { + + return profileInfo || {}; + }, + + async refreshAccessToken(context) { + + const tokenUrl = 'https://app.asana.com/-/oauth_token?' + + 'grant_type=refresh_token&' + + `refresh_token=${context.refreshToken}&` + + `redirect_uri=${encodeURIComponent(context.callbackUrl)}&` + + `client_id=${context.clientId}&` + + `client_secret=${context.clientSecret}`; + + const { data: result } = await context.httpRequest({ + method: 'POST', + url: tokenUrl, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' } + }); + + profileInfo = result.data; + + const newDate = new Date(); + newDate.setTime(newDate.getTime() + (result.expires_in * 1000)); + + return { + accessToken: result.access_token, + accessTokenExpDate: newDate + }; + }, + + validateAccessToken: { + method: 'GET', + url: 'https://app.asana.com/api/1.0/users/me', + auth: { + bearer: '{{accessToken}}' + } + } + }; + } +}; diff --git a/src/appmixer/asanamcp/bundle.json b/src/appmixer/asanamcp/bundle.json new file mode 100644 index 0000000000..1f9f356784 --- /dev/null +++ b/src/appmixer/asanamcp/bundle.json @@ -0,0 +1,9 @@ +{ + "name": "appmixer.asanamcp", + "version": "1.0.0", + "changelog": { + "1.0.0": [ + "Initial version — Asana MCP connector with dynamic tool discovery and execution." + ] + } +} diff --git a/src/appmixer/asanamcp/core/CallTool/CallTool.js b/src/appmixer/asanamcp/core/CallTool/CallTool.js new file mode 100644 index 0000000000..d4c8411800 --- /dev/null +++ b/src/appmixer/asanamcp/core/CallTool/CallTool.js @@ -0,0 +1,57 @@ +'use strict'; + +const { createClient, parseToolResult } = require('../../mcp-commons'); + +module.exports = { + + async receive(context) { + + const { toolName } = context.properties; + const input = context.messages.in.content; + + // Remove toolName from input if accidentally passed through + const args = { ...input }; + delete args.toolName; + + // Convert string representations of arrays/objects back to their types + // (Appmixer inputs may stringify complex types) + for (const [key, value] of Object.entries(args)) { + if (typeof value === 'string') { + try { + const parsed = JSON.parse(value); + if (typeof parsed === 'object') { + args[key] = parsed; + } + } catch (e) { + // Keep as string + } + } + } + + // Remove empty/undefined values + for (const key of Object.keys(args)) { + if (args[key] === undefined || args[key] === null || args[key] === '') { + delete args[key]; + } + } + + const client = createClient(context); + + // Initialize session + await client.initialize(context.httpRequest.bind(context)); + + // Call the selected tool + const result = await client.callTool(toolName, args, context.httpRequest.bind(context)); + + // Parse the MCP result into a usable format + const parsed = parseToolResult(result); + + return context.sendJson({ + toolName, + text: parsed.text, + json: parsed.json, + isError: parsed.isError, + raw: parsed.raw + }, 'out'); + } +}; diff --git a/src/appmixer/asanamcp/core/CallTool/component.json b/src/appmixer/asanamcp/core/CallTool/component.json new file mode 100644 index 0000000000..089d389a55 --- /dev/null +++ b/src/appmixer/asanamcp/core/CallTool/component.json @@ -0,0 +1,77 @@ +{ + "name": "appmixer.asanamcp.core.CallTool", + "description": "Call any tool on the Asana MCP server. Select a tool and provide its inputs — the input form is dynamically generated based on the tool's schema.", + "auth": { + "service": "appmixer:asanamcp" + }, + "properties": { + "schema": { + "properties": { + "toolName": { + "type": "string" + } + }, + "required": [ + "toolName" + ] + }, + "inspector": { + "inputs": { + "toolName": { + "type": "select", + "label": "Tool", + "index": 1, + "tooltip": "Select an MCP tool to call. Available tools are fetched dynamically from the Asana MCP server.", + "source": { + "url": "/component/appmixer/asanamcp/core/ListTools?outPort=out", + "data": { + "transform": "./ListTools#toolsToSelectArray" + } + } + } + } + } + }, + "inPorts": [ + { + "name": "in", + "source": { + "url": "/component/appmixer/asanamcp/core/ListTools?outPort=out", + "data": { + "messages": { + "in/toolName": "properties/toolName" + }, + "transform": "./ListTools#toolInputToInspector" + } + } + } + ], + "outPorts": [ + { + "name": "out", + "options": [ + { + "label": "Tool Name", + "value": "toolName" + }, + { + "label": "Result Text", + "value": "text" + }, + { + "label": "Result JSON", + "value": "json" + }, + { + "label": "Is Error", + "value": "isError" + }, + { + "label": "Raw Response", + "value": "raw" + } + ] + } + ], + "icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjggMTI4Ij48cmFkaWFsR3JhZGllbnQgaWQ9ImEiIGN4PSI2NCIgY3k9IjEyOCIgcj0iMTI4IiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjZmZiOTAwIi8+PHN0b3Agb2Zmc2V0PSIuNiIgc3RvcC1jb2xvcj0iI2Y5NWQ4ZiIvPjxzdG9wIG9mZnNldD0iLjk5IiBzdG9wLWNvbG9yPSIjZjk1MzUzIi8+PC9yYWRpYWxHcmFkaWVudD48cGF0aCBmaWxsPSJ1cmwoI2EpIiBkPSJNOTguMDQ0IDc1LjkxN2MtMTMuMjU2IDAtMjQuMDA0IDEwLjc0OC0yNC4wMDQgMjQuMDA0UzQ4LjY1IDEyNCAxMi40IDEyNHMtMjQuMDA0LTEwLjc0OC0yNC4wMDQtMjQuMDA0czEwLjc0OC0yNC4wMDQgMjQuMDA0LTI0LjAwNGMxMy4yNTYgMCAyNC4wMDQgMTAuNzQ4IDI0LjAwNCAyNC4wMDRTNDguNjUgNzUuOTE3IDk4LjA0NCA3NS45MTdzMjQuMDA0IDEwLjc0OCAyNC4wMDQgMjQuMDA0UzExMS4zIDEyNCA5OC4wNDQgMTI0czI0LjAwNC0xMC43NDggMjQuMDA0LTI0LjAwNC0xMC43NDgtMjQuMDA0LTI0LjAwNC0yNC4wMDR6TTY0IDQ4LjA3Yy0xMy4yNTYgMC0yNC4wMDQtMTAuNzQ4LTI0LjAwNC0yNC4wMDRTNTAuNzQ0IDAgNjQgMHMyNC4wMDQgMTAuNzQ4IDI0LjAwNCAyNC4wMDRTNzcuMjU2IDQ4LjA3IDY0IDQ4LjA3eiIvPjwvc3ZnPg==" +} diff --git a/src/appmixer/asanamcp/core/CallToolAI/CallToolAI.js b/src/appmixer/asanamcp/core/CallToolAI/CallToolAI.js new file mode 100644 index 0000000000..4ec67da2d9 --- /dev/null +++ b/src/appmixer/asanamcp/core/CallToolAI/CallToolAI.js @@ -0,0 +1,135 @@ +'use strict'; + +const { createClient, parseToolResult } = require('../../mcp-commons'); + +/** + * AI-powered MCP tool caller. + * + * This component uses an LLM to: + * 1. Discover available tools from the MCP server + * 2. Select the appropriate tool(s) based on a natural language prompt + * 3. Generate tool arguments + * 4. Execute tool calls + * 5. Summarize results + * + * NOTE: This is a scaffold. The actual LLM integration depends on the AI infrastructure + * available in the Appmixer instance. The implementation below uses a simple keyword-based + * approach that should be adapted to the specific AI provider. + */ +module.exports = { + + async receive(context) { + + const { prompt, context: additionalContext } = context.messages.in.content; + const maxSteps = context.properties.maxSteps || 3; + + const client = createClient(context); + + // Initialize MCP session + await client.initialize(context.httpRequest.bind(context)); + + // Get available tools + const tools = await client.listAllTools(context.httpRequest.bind(context)); + + const steps = []; + const results = []; + const toolsCalled = []; + + // Simple single-step execution: find matching tool and call it + // For full AI agent loop, integrate with an LLM provider here + const matchedTool = findBestToolMatch(prompt, tools); + + if (matchedTool) { + try { + const args = extractArgsFromPrompt(prompt, matchedTool); + const result = await client.callTool(matchedTool.name, args, context.httpRequest.bind(context)); + const parsed = parseToolResult(result); + + toolsCalled.push(matchedTool.name); + results.push(parsed); + steps.push({ + tool: matchedTool.name, + args, + result: parsed.text, + isError: parsed.isError + }); + } catch (err) { + steps.push({ + tool: matchedTool.name, + error: err.message + }); + } + } + + // Generate answer summary + const answer = results.length > 0 + ? results.map(r => r.text).join('\n\n') + : `No matching tool found for: "${prompt}". Available tools: ${tools.map(t => t.name).join(', ')}`; + + return context.sendJson({ + answer, + toolsCalled, + results, + steps + }, 'out'); + } +}; + +/** + * Simple keyword-based tool matching. + * In a full implementation, this would be replaced by an LLM call. + */ +function findBestToolMatch(prompt, tools) { + + const promptLower = prompt.toLowerCase(); + let bestMatch = null; + let bestScore = 0; + + for (const tool of tools) { + let score = 0; + const nameWords = tool.name.toLowerCase().split(/[_\-\s]+/); + const descWords = (tool.description || '').toLowerCase().split(/\s+/); + + for (const word of nameWords) { + if (word.length > 2 && promptLower.includes(word)) { + score += 3; + } + } + + for (const word of descWords) { + if (word.length > 3 && promptLower.includes(word)) { + score += 1; + } + } + + if (score > bestScore) { + bestScore = score; + bestMatch = tool; + } + } + + return bestScore > 0 ? bestMatch : null; +} + +/** + * Simple argument extraction from prompt. + * In a full implementation, this would be replaced by an LLM call. + */ +function extractArgsFromPrompt(prompt, tool) { + + const schema = tool.inputSchema; + if (!schema || !schema.properties) return {}; + + const args = {}; + const props = schema.properties; + + const queryKeys = ['query', 'search', 'filter', 'q', 'name', 'text', 'term']; + for (const key of queryKeys) { + if (props[key]) { + args[key] = prompt; + break; + } + } + + return args; +} diff --git a/src/appmixer/asanamcp/core/CallToolAI/component.json b/src/appmixer/asanamcp/core/CallToolAI/component.json new file mode 100644 index 0000000000..e1915ac3b8 --- /dev/null +++ b/src/appmixer/asanamcp/core/CallToolAI/component.json @@ -0,0 +1,101 @@ +{ + "name": "appmixer.asanamcp.core.CallToolAI", + "description": "AI-powered tool calling for Asana MCP. Send a natural language prompt and an AI agent will select the appropriate tool, provide the arguments, and execute it.", + "auth": { + "service": "appmixer:asanamcp" + }, + "properties": { + "schema": { + "properties": { + "model": { + "type": "string" + }, + "maxSteps": { + "type": "number" + } + } + }, + "inspector": { + "inputs": { + "model": { + "type": "select", + "label": "AI Model", + "index": 1, + "defaultValue": "gpt-4o", + "tooltip": "Select the AI model to use for tool selection and argument generation.", + "options": [ + { "content": "GPT-4o", "value": "gpt-4o" }, + { "content": "GPT-4o Mini", "value": "gpt-4o-mini" }, + { "content": "Claude 3.5 Sonnet", "value": "claude-3-5-sonnet-20241022" } + ] + }, + "maxSteps": { + "type": "number", + "label": "Max Steps", + "index": 2, + "defaultValue": 3, + "tooltip": "Maximum number of tool calls the AI agent can make in a single execution. Higher values allow more complex multi-step queries." + } + } + } + }, + "inPorts": [ + { + "name": "in", + "schema": { + "type": "object", + "properties": { + "prompt": { + "type": "string" + }, + "context": { + "type": "string" + } + }, + "required": [ + "prompt" + ] + }, + "inspector": { + "inputs": { + "prompt": { + "type": "textarea", + "label": "Prompt", + "index": 1, + "tooltip": "Natural language instruction for what you want to do with Asana. Example: 'Find all my incomplete tasks due this week'" + }, + "context": { + "type": "textarea", + "label": "Additional Context", + "index": 2, + "tooltip": "Optional additional context to help the AI understand your request better." + } + } + } + } + ], + "outPorts": [ + { + "name": "out", + "options": [ + { + "label": "Answer", + "value": "answer" + }, + { + "label": "Tools Called", + "value": "toolsCalled" + }, + { + "label": "Results", + "value": "results" + }, + { + "label": "Steps", + "value": "steps" + } + ] + } + ], + "icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjggMTI4Ij48cmFkaWFsR3JhZGllbnQgaWQ9ImEiIGN4PSI2NCIgY3k9IjEyOCIgcj0iMTI4IiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjZmZiOTAwIi8+PHN0b3Agb2Zmc2V0PSIuNiIgc3RvcC1jb2xvcj0iI2Y5NWQ4ZiIvPjxzdG9wIG9mZnNldD0iLjk5IiBzdG9wLWNvbG9yPSIjZjk1MzUzIi8+PC9yYWRpYWxHcmFkaWVudD48cGF0aCBmaWxsPSJ1cmwoI2EpIiBkPSJNOTguMDQ0IDc1LjkxN2MtMTMuMjU2IDAtMjQuMDA0IDEwLjc0OC0yNC4wMDQgMjQuMDA0UzQ4LjY1IDEyNCAxMi40IDEyNHMtMjQuMDA0LTEwLjc0OC0yNC4wMDQtMjQuMDA0czEwLjc0OC0yNC4wMDQgMjQuMDA0LTI0LjAwNGMxMy4yNTYgMCAyNC4wMDQgMTAuNzQ4IDI0LjAwNCAyNC4wMDRTNDguNjUgNzUuOTE3IDk4LjA0NCA3NS45MTdzMjQuMDA0IDEwLjc0OCAyNC4wMDQgMjQuMDA0UzExMS4zIDEyNCA5OC4wNDQgMTI0czI0LjAwNC0xMC43NDggMjQuMDA0LTI0LjAwNC0xMC43NDgtMjQuMDA0LTI0LjAwNC0yNC4wMDR6TTY0IDQ4LjA3Yy0xMy4yNTYgMC0yNC4wMDQtMTAuNzQ4LTI0LjAwNC0yNC4wMDRTNTAuNzQ0IDAgNjQgMHMyNC4wMDQgMTAuNzQ4IDI0LjAwNCAyNC4wMDRTNzcuMjU2IDQ4LjA3IDY0IDQ4LjA3eiIvPjwvc3ZnPg==" +} diff --git a/src/appmixer/asanamcp/core/ListTools/ListTools.js b/src/appmixer/asanamcp/core/ListTools/ListTools.js new file mode 100644 index 0000000000..0867931d09 --- /dev/null +++ b/src/appmixer/asanamcp/core/ListTools/ListTools.js @@ -0,0 +1,32 @@ +'use strict'; + +const { createClient, toolsToSelectArray, toolInputToInspector } = require('../../mcp-commons'); + +module.exports = { + + async receive(context) { + + const client = createClient(context); + + // Initialize MCP session first + await client.initialize(context.httpRequest.bind(context)); + + // Fetch all tools + const tools = await client.listAllTools(context.httpRequest.bind(context)); + + return context.sendJson(tools, 'out'); + }, + + /** + * Transform: tools array → select dropdown options. + * Referenced from CallTool component.json as source transform. + */ + toolsToSelectArray, + + /** + * Transform: tools array + selected toolName → dynamic Appmixer inspector. + * This generates the input fields for the selected MCP tool. + * Referenced from CallTool component.json inPorts source transform. + */ + toolInputToInspector +}; diff --git a/src/appmixer/asanamcp/core/ListTools/component.json b/src/appmixer/asanamcp/core/ListTools/component.json new file mode 100644 index 0000000000..d38a573b0c --- /dev/null +++ b/src/appmixer/asanamcp/core/ListTools/component.json @@ -0,0 +1,28 @@ +{ + "name": "appmixer.asanamcp.core.ListTools", + "description": "Lists available tools from the Asana MCP server. Used as a helper component for dynamic tool selection and input generation.", + "private": true, + "auth": { + "service": "appmixer:asanamcp" + }, + "inPorts": [ + { + "name": "in", + "schema": { + "type": "object", + "properties": { + "toolName": { + "type": "string" + } + } + } + } + ], + "outPorts": [ + { + "name": "out", + "options": [] + } + ], + "icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjggMTI4Ij48cmFkaWFsR3JhZGllbnQgaWQ9ImEiIGN4PSI2NCIgY3k9IjEyOCIgcj0iMTI4IiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjZmZiOTAwIi8+PHN0b3Agb2Zmc2V0PSIuNiIgc3RvcC1jb2xvcj0iI2Y5NWQ4ZiIvPjxzdG9wIG9mZnNldD0iLjk5IiBzdG9wLWNvbG9yPSIjZjk1MzUzIi8+PC9yYWRpYWxHcmFkaWVudD48cGF0aCBmaWxsPSJ1cmwoI2EpIiBkPSJNOTguMDQ0IDc1LjkxN2MtMTMuMjU2IDAtMjQuMDA0IDEwLjc0OC0yNC4wMDQgMjQuMDA0UzQ4LjY1IDEyNCAxMi40IDEyNHMtMjQuMDA0LTEwLjc0OC0yNC4wMDQtMjQuMDA0czEwLjc0OC0yNC4wMDQgMjQuMDA0LTI0LjAwNGMxMy4yNTYgMCAyNC4wMDQgMTAuNzQ4IDI0LjAwNCAyNC4wMDRTNDguNjUgNzUuOTE3IDk4LjA0NCA3NS45MTdzMjQuMDA0IDEwLjc0OCAyNC4wMDQgMjQuMDA0UzExMS4zIDEyNCA5OC4wNDQgMTI0czI0LjAwNC0xMC43NDggMjQuMDA0LTI0LjAwNC0xMC43NDgtMjQuMDA0LTI0LjAwNC0yNC4wMDR6TTY0IDQ4LjA3Yy0xMy4yNTYgMC0yNC4wMDQtMTAuNzQ4LTI0LjAwNC0yNC4wMDRTNTAuNzQ0IDAgNjQgMHMyNC4wMDQgMTAuNzQ4IDI0LjAwNCAyNC4wMDRTNzcuMjU2IDQ4LjA3IDY0IDQ4LjA3eiIvPjwvc3ZnPg==" +} diff --git a/src/appmixer/asanamcp/mcp-commons.js b/src/appmixer/asanamcp/mcp-commons.js new file mode 100644 index 0000000000..5aae0c8c18 --- /dev/null +++ b/src/appmixer/asanamcp/mcp-commons.js @@ -0,0 +1,332 @@ +'use strict'; + +/** + * MCP (Model Context Protocol) client for Streamable HTTP transport. + * Handles communication with remote MCP servers like Asana's MCP server. + * + * This module is designed to be duplicated per MCP vendor connector, + * as Appmixer's sandboxing prevents cross-connector imports. + */ + +const MCP_SERVER_URL = 'https://mcp.asana.com/v2/mcp'; +const JSONRPC_VERSION = '2.0'; + +class McpClient { + + constructor(accessToken, serverUrl) { + this.serverUrl = serverUrl || MCP_SERVER_URL; + this.accessToken = accessToken; + this.sessionId = null; + } + + /** + * Send a JSON-RPC request to the MCP server via Streamable HTTP transport. + * @param {string} method - JSON-RPC method name + * @param {object} [params] - Method parameters + * @param {function} httpRequest - context.httpRequest or equivalent + * @returns {object} JSON-RPC result + */ + async request(method, params, httpRequest) { + + const body = { + jsonrpc: JSONRPC_VERSION, + id: Date.now(), + method, + params: params || {} + }; + + const headers = { + 'Content-Type': 'application/json', + 'Accept': 'application/json, text/event-stream', + 'Authorization': `Bearer ${this.accessToken}` + }; + + if (this.sessionId) { + headers['Mcp-Session-Id'] = this.sessionId; + } + + const response = await httpRequest({ + method: 'POST', + url: this.serverUrl, + headers, + data: body + }); + + // Capture session ID from response headers if present + const newSessionId = response.headers?.['mcp-session-id']; + if (newSessionId) { + this.sessionId = newSessionId; + } + + // Handle JSON-RPC response + const data = response.data; + + if (data.error) { + const err = new Error(data.error.message || 'MCP server error'); + err.code = data.error.code; + err.data = data.error.data; + throw err; + } + + return data.result; + } + + /** + * Initialize the MCP session. + * @param {function} httpRequest + * @returns {object} Server capabilities + */ + async initialize(httpRequest) { + + return this.request('initialize', { + protocolVersion: '2025-06-18', + capabilities: {}, + clientInfo: { + name: 'Appmixer', + version: '1.0.0' + } + }, httpRequest); + } + + /** + * List available tools from the MCP server. + * @param {function} httpRequest + * @param {string} [cursor] - Pagination cursor + * @returns {object} { tools: Array, nextCursor?: string } + */ + async listTools(httpRequest, cursor) { + + const params = {}; + if (cursor) { + params.cursor = cursor; + } + return this.request('tools/list', params, httpRequest); + } + + /** + * List all tools, handling pagination. + * @param {function} httpRequest + * @returns {Array} All available tools + */ + async listAllTools(httpRequest) { + + let allTools = []; + let cursor = undefined; + + do { + const result = await this.listTools(httpRequest, cursor); + allTools = allTools.concat(result.tools || []); + cursor = result.nextCursor; + } while (cursor); + + return allTools; + } + + /** + * Call a tool on the MCP server. + * @param {string} name - Tool name + * @param {object} args - Tool arguments + * @param {function} httpRequest + * @returns {object} Tool result + */ + async callTool(name, args, httpRequest) { + + return this.request('tools/call', { name, arguments: args }, httpRequest); + } +} + +/** + * Create an McpClient from Appmixer context. + * @param {object} context - Appmixer component context + * @returns {McpClient} + */ +function createClient(context) { + + return new McpClient(context.auth.accessToken); +} + +/** + * Transform MCP tools list to Appmixer select array. + * Used as a source transform for tool selection dropdowns. + * @param {Array} tools - Array of MCP tool objects + * @returns {Array} Appmixer select options + */ +function toolsToSelectArray(tools) { + + if (!Array.isArray(tools)) return []; + + return tools.map(tool => ({ + label: tool.name + (tool.description ? ` — ${tool.description}` : ''), + value: tool.name + })); +} + +/** + * Transform a single MCP tool's inputSchema (JSON Schema) into an Appmixer inspector. + * This is the core of dynamic UI generation for MCP tools. + * + * @param {Array} tools - All tools from ListTools + * @param {object} message - Contains in/toolName + * @returns {object} Appmixer inspector { schema, inputs } + */ +function toolInputToInspector(tools, message) { + + const toolName = message['in/toolName']; + if (!toolName || !Array.isArray(tools)) { + return { schema: { properties: {} }, inputs: {} }; + } + + const tool = tools.find(t => t.name === toolName); + if (!tool || !tool.inputSchema) { + return { schema: { properties: {} }, inputs: {} }; + } + + const inputSchema = tool.inputSchema; + const properties = inputSchema.properties || {}; + const required = inputSchema.required || []; + + const inspector = { + schema: { + type: 'object', + properties: {}, + required: [] + }, + inputs: {} + }; + + let index = 1; + for (const [key, schema] of Object.entries(properties)) { + // Map JSON Schema type to Appmixer input type + inspector.inputs[key] = { + type: mapSchemaToInputType(schema), + label: schema.title || formatLabel(key), + index: index++, + tooltip: schema.description || '' + }; + + // Set default value if present + if (schema.default !== undefined) { + inspector.inputs[key].defaultValue = schema.default; + } + + // Handle enums as select + if (schema.enum) { + inspector.inputs[key].type = 'select'; + inspector.inputs[key].options = schema.enum.map(v => ({ + content: String(v), + value: v + })); + } + + // Schema property + inspector.schema.properties[key] = { + type: mapSchemaToJsonType(schema) + }; + + // Required fields + if (required.includes(key)) { + inspector.schema.required.push(key); + } + } + + return inspector; +} + +/** + * Map JSON Schema type to Appmixer input widget type. + */ +function mapSchemaToInputType(schema) { + + if (schema.enum) return 'select'; + + switch (schema.type) { + case 'boolean': + return 'toggle'; + case 'integer': + case 'number': + return 'number'; + case 'array': + return 'textarea'; + case 'object': + return 'textarea'; + case 'string': + default: + return 'text'; + } +} + +/** + * Map JSON Schema type to JSON Schema type string for Appmixer schema validation. + */ +function mapSchemaToJsonType(schema) { + + switch (schema.type) { + case 'boolean': + return 'boolean'; + case 'integer': + case 'number': + return 'number'; + case 'array': + return 'string'; // JSON string representation + case 'object': + return 'string'; // JSON string representation + case 'string': + default: + return 'string'; + } +} + +/** + * Format a camelCase or snake_case key into a human-readable label. + */ +function formatLabel(key) { + + return key + .replace(/([A-Z])/g, ' $1') + .replace(/_/g, ' ') + .replace(/^\w/, c => c.toUpperCase()) + .trim(); +} + +/** + * Parse tool result content into a flat output object. + * MCP tool results have a content array with type/text entries. + * @param {object} result - MCP tool/call result + * @returns {object} Parsed result + */ +function parseToolResult(result) { + + if (!result || !result.content) { + return { raw: result }; + } + + const textContent = result.content + .filter(c => c.type === 'text') + .map(c => c.text) + .join('\n'); + + // Try to parse as JSON + let parsed = null; + try { + parsed = JSON.parse(textContent); + } catch (e) { + // Not JSON, keep as text + } + + return { + text: textContent, + json: parsed, + isError: result.isError || false, + raw: result + }; +} + +module.exports = { + McpClient, + createClient, + toolsToSelectArray, + toolInputToInspector, + parseToolResult, + formatLabel, + MCP_SERVER_URL +}; diff --git a/src/appmixer/asanamcp/package.json b/src/appmixer/asanamcp/package.json new file mode 100644 index 0000000000..bb0cd3a8bc --- /dev/null +++ b/src/appmixer/asanamcp/package.json @@ -0,0 +1,6 @@ +{ + "name": "appmixer.asanamcp", + "version": "1.0.0", + "private": true, + "description": "Asana MCP connector for Appmixer" +} diff --git a/src/appmixer/asanamcp/service.json b/src/appmixer/asanamcp/service.json new file mode 100644 index 0000000000..85fde1dd55 --- /dev/null +++ b/src/appmixer/asanamcp/service.json @@ -0,0 +1,8 @@ +{ + "name": "appmixer.asanamcp", + "label": "Asana MCP", + "category": "applications", + "description": "Asana MCP (Model Context Protocol) integration. Enables AI-powered access to Asana Work Graph — tasks, projects, and portfolios — through the official Asana remote MCP server.", + "icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjggMTI4Ij48cmFkaWFsR3JhZGllbnQgaWQ9ImEiIGN4PSI2NCIgY3k9IjEyOCIgcj0iMTI4IiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjZmZiOTAwIi8+PHN0b3Agb2Zmc2V0PSIuNiIgc3RvcC1jb2xvcj0iI2Y5NWQ4ZiIvPjxzdG9wIG9mZnNldD0iLjk5IiBzdG9wLWNvbG9yPSIjZjk1MzUzIi8+PC9yYWRpYWxHcmFkaWVudD48cGF0aCBmaWxsPSJ1cmwoI2EpIiBkPSJNOTguMDQ0IDc1LjkxN2MtMTMuMjU2IDAtMjQuMDA0IDEwLjc0OC0yNC4wMDQgMjQuMDA0UzQ4LjY1IDEyNCAxMi40IDEyNHMtMjQuMDA0LTEwLjc0OC0yNC4wMDQtMjQuMDA0czEwLjc0OC0yNC4wMDQgMjQuMDA0LTI0LjAwNGMxMy4yNTYgMCAyNC4wMDQgMTAuNzQ4IDI0LjAwNCAyNC4wMDRTNDguNjUgNzUuOTE3IDk4LjA0NCA3NS45MTdzMjQuMDA0IDEwLjc0OCAyNC4wMDQgMjQuMDA0UzExMS4zIDEyNCA5OC4wNDQgMTI0czI0LjAwNC0xMC43NDggMjQuMDA0LTI0LjAwNC0xMC43NDgtMjQuMDA0LTI0LjAwNC0yNC4wMDR6TTY0IDQ4LjA3Yy0xMy4yNTYgMC0yNC4wMDQtMTAuNzQ4LTI0LjAwNC0yNC4wMDRTNTAuNzQ0IDAgNjQgMHMyNC4wMDQgMTAuNzQ4IDI0LjAwNCAyNC4wMDRTNzcuMjU2IDQ4LjA3IDY0IDQ4LjA3eiIvPjwvc3ZnPg==", + "version": "1.0.0" +} diff --git a/src/appmixer/jira/bundle.json b/src/appmixer/jira/bundle.json index 62d2979cfb..2c5cea1f4e 100644 --- a/src/appmixer/jira/bundle.json +++ b/src/appmixer/jira/bundle.json @@ -1,6 +1,6 @@ { "name": "appmixer.jira", - "version": "2.0.2", + "version": "2.0.3", "changelog": { "1.0.0": [ "Initial version" @@ -51,6 +51,9 @@ ], "2.0.2": [ "Create Issue, Update Issue: fix support for custom fields." + ], + "2.0.3": [ + "CreateIssue, IssueMetadata: fix field metadata fetch to support both old ({fields}) and new paginated ({values}) Jira API response formats." ] } -} +} \ No newline at end of file diff --git a/src/appmixer/jira/issues/CreateIssue/CreateIssue.js b/src/appmixer/jira/issues/CreateIssue/CreateIssue.js index dd1c090ba3..3b06a7e143 100644 --- a/src/appmixer/jira/issues/CreateIssue/CreateIssue.js +++ b/src/appmixer/jira/issues/CreateIssue/CreateIssue.js @@ -85,10 +85,11 @@ module.exports = { const hasCustomFields = Object.keys(issue).some(key => key.startsWith('customfield_')); if (hasCustomFields) { try { - const { fields } = await commons.get( + const response = await commons.get( `${apiUrl}issue/createmeta/${project}/issuetypes/${issueType}`, auth ); + const fields = response.fields || response.values; if (fields) { const fieldMeta = fields.reduce((acc, field) => { acc[field.fieldId] = field; diff --git a/src/appmixer/jira/issues/IssueMetadata/IssueMetadata.js b/src/appmixer/jira/issues/IssueMetadata/IssueMetadata.js index 6ddb90398e..4e9299da87 100644 --- a/src/appmixer/jira/issues/IssueMetadata/IssueMetadata.js +++ b/src/appmixer/jira/issues/IssueMetadata/IssueMetadata.js @@ -65,18 +65,20 @@ module.exports = { } if (!issueType) { - const { issueTypes } = await commons.get( + const response = await commons.get( `${apiUrl}issue/createmeta/${project}/issuetypes`, auth ); + const issueTypes = response.issueTypes || response.values || []; - return context.sendJson(issueTypes || [], 'out'); + return context.sendJson(issueTypes, 'out'); } - const { fields } = await commons.get( + const response = await commons.get( `${apiUrl}issue/createmeta/${project}/issuetypes/${issueType}`, auth ); + const fields = response.fields || response.values; if (!fields) { return context.sendJson([], 'out');