diff --git a/src/appmixer/hubspotmcp/auth.js b/src/appmixer/hubspotmcp/auth.js new file mode 100644 index 0000000000..ecf1f45be5 --- /dev/null +++ b/src/appmixer/hubspotmcp/auth.js @@ -0,0 +1,50 @@ +'use strict'; + +/** + * OAuth 2.1 + PKCE authentication for HubSpot MCP server. + * + * HubSpot MCP Auth Apps use a separate OAuth flow from the standard HubSpot OAuth. + * The MCP Auth App is created in HubSpot's Development > MCP Auth Apps section. + * Scopes are determined dynamically during installation based on available MCP tools + * and user permissions — they are NOT explicitly defined in this auth config. + * + * @see https://developers.hubspot.com/docs/apps/developer-platform/build-apps/integrate-with-the-remote-hubspot-mcp-server + */ +module.exports = { + + type: 'oauth2', + + definition: { + + accountNameFromProfileInfo: 'name', + + // MCP scopes are determined at installation time by the MCP server. + // We request no explicit scopes — the user grants permissions during the OAuth flow. + scope: [], + scopeDelimiter: ' ', + + authUrl: 'https://app.hubspot.com/oauth/authorize', + + requestAccessToken: 'https://api.hubapi.com/oauth/v1/token', + + requestProfileInfo: { + method: 'GET', + url: 'https://api.hubapi.com/oauth/v1/access-tokens/{{accessToken}}', + headers: { + 'Authorization': 'Bearer {{accessToken}}', + 'User-Agent': 'Appmixer' + } + }, + + refreshAccessToken: 'https://api.hubapi.com/oauth/v1/token', + + validateAccessToken: { + method: 'GET', + url: 'https://api.hubapi.com/oauth/v1/access-tokens/{{accessToken}}', + headers: { + 'Authorization': 'Bearer {{accessToken}}', + 'User-Agent': 'Appmixer' + } + } + } +}; diff --git a/src/appmixer/hubspotmcp/bundle.json b/src/appmixer/hubspotmcp/bundle.json new file mode 100644 index 0000000000..752339ca35 --- /dev/null +++ b/src/appmixer/hubspotmcp/bundle.json @@ -0,0 +1,9 @@ +{ + "name": "appmixer.hubspotmcp", + "version": "1.0.0", + "changelog": { + "1.0.0": [ + "Initial version — HubSpot MCP connector with dynamic tool discovery and execution." + ] + } +} diff --git a/src/appmixer/hubspotmcp/core/CallTool/CallTool.js b/src/appmixer/hubspotmcp/core/CallTool/CallTool.js new file mode 100644 index 0000000000..d4c8411800 --- /dev/null +++ b/src/appmixer/hubspotmcp/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/hubspotmcp/core/CallTool/component.json b/src/appmixer/hubspotmcp/core/CallTool/component.json new file mode 100644 index 0000000000..1663aacad6 --- /dev/null +++ b/src/appmixer/hubspotmcp/core/CallTool/component.json @@ -0,0 +1,77 @@ +{ + "name": "appmixer.hubspotmcp.core.CallTool", + "description": "Call any tool on the HubSpot MCP server. Select a tool and provide its inputs — the input form is dynamically generated based on the tool's schema.", + "auth": { + "service": "appmixer:hubspotmcp" + }, + "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 HubSpot MCP server.", + "source": { + "url": "/component/appmixer/hubspotmcp/core/ListTools?outPort=out", + "data": { + "transform": "./ListTools#toolsToSelectArray" + } + } + } + } + } + }, + "inPorts": [ + { + "name": "in", + "source": { + "url": "/component/appmixer/hubspotmcp/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,PHN2ZyBoZWlnaHQ9IjI1MDAiIHZpZXdCb3g9IjYuMjA4NTYyODMgLjY0NDk4ODI0IDI0NC4yNjk0MzcxNyAyNTEuMjQ3MDExNzYiIHdpZHRoPSIyNTAwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxwYXRoIGQ9Im0xOTEuMzg1IDg1LjY5NHYtMjkuNTA2YTIyLjcyMiAyMi43MjIgMCAwIDAgMTMuMTAxLTIwLjQ4di0uNjc3YzAtMTIuNTQ5LTEwLjE3My0yMi43MjItMjIuNzIxLTIyLjcyMmgtLjY3OGMtMTIuNTQ5IDAtMjIuNzIyIDEwLjE3My0yMi43MjIgMjIuNzIydi42NzdhMjIuNzIyIDIyLjcyMiAwIDAgMCAxMy4xMDEgMjAuNDh2MjkuNTA2YTY0LjM0MiA2NC4zNDIgMCAwIDAtMzAuNTk0IDEzLjQ3bC04MC45MjItNjMuMDNjLjU3Ny0yLjA4My44NzgtNC4yMjUuOTEyLTYuMzc1YTI1LjYgMjUuNiAwIDEgMC0yNS42MzMgMjUuNTUgMjUuMzIzIDI1LjMyMyAwIDAgMCAxMi42MDctMy40M2w3OS42ODUgNjIuMDA3Yy0xNC42NSAyMi4xMzEtMTQuMjU4IDUwLjk3NC45ODcgNzIuN2wtMjQuMjM2IDI0LjI0M2MtMS45Ni0uNjI2LTQtLjk1OS02LjA1Ny0uOTg3LTExLjYwNy4wMS0yMS4wMSA5LjQyMy0yMS4wMDcgMjEuMDMuMDAzIDExLjYwNiA5LjQxMiAyMS4wMTQgMjEuMDE4IDIxLjAxNyAxMS42MDcuMDAzIDIxLjAyLTkuNCAyMS4wMy0yMS4wMDdhMjAuNzQ3IDIwLjc0NyAwIDAgMC0uOTg4LTYuMDU2bDIzLjk3Ni0yMy45ODVjMjEuNDIzIDE2LjQ5MiA1MC44NDYgMTcuOTEzIDczLjc1OSAzLjU2MiAyMi45MTItMTQuMzUyIDM0LjQ3NS00MS40NDYgMjguOTg1LTY3LjkxOC01LjQ5LTI2LjQ3My0yNi44NzMtNDYuNzM0LTUzLjYwMy01MC43OTJtLTkuOTM4IDk3LjA0NGEzMy4xNyAzMy4xNyAwIDEgMSAwLTY2LjMxNmMxNy44NS42MjUgMzIgMTUuMjcyIDMyLjAxIDMzLjEzNC4wMDggMTcuODYtMTQuMTI3IDMyLjUyMi0zMS45NzcgMzMuMTY1IiBmaWxsPSIjZmY3YTU5Ii8+PC9zdmc+" +} diff --git a/src/appmixer/hubspotmcp/core/CallToolAI/CallToolAI.js b/src/appmixer/hubspotmcp/core/CallToolAI/CallToolAI.js new file mode 100644 index 0000000000..290dca45d4 --- /dev/null +++ b/src/appmixer/hubspotmcp/core/CallToolAI/CallToolAI.js @@ -0,0 +1,151 @@ +'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 + * + * It acts as an AI agent that can chain multiple tool calls to fulfill complex requests. + * + * NOTE: This is a scaffold. The actual LLM integration depends on the AI infrastructure + * available in the Appmixer instance (e.g., OpenAI API, Anthropic API, or Appmixer's + * built-in AI capabilities). The implementation below uses a simple prompt-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)); + + // Build tool descriptions for the AI + const toolDescriptions = tools.map(tool => ({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema + })); + + 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 that selects + * the best tool based on the prompt and tool descriptions. + */ +function findBestToolMatch(prompt, tools) { + + const promptLower = prompt.toLowerCase(); + + // Score each tool based on keyword overlap + 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 that + * generates the correct arguments based on the tool's inputSchema. + */ +function extractArgsFromPrompt(prompt, tool) { + + // For now, pass the prompt as a query/search parameter if the tool accepts one + const schema = tool.inputSchema; + if (!schema || !schema.properties) return {}; + + const args = {}; + const props = schema.properties; + + // Common patterns: look for query/search/filter/name type parameters + const queryKeys = ['query', 'search', 'filter', 'q', 'name', 'email', 'term']; + for (const key of queryKeys) { + if (props[key]) { + args[key] = prompt; + break; + } + } + + return args; +} diff --git a/src/appmixer/hubspotmcp/core/CallToolAI/component.json b/src/appmixer/hubspotmcp/core/CallToolAI/component.json new file mode 100644 index 0000000000..f979ee0604 --- /dev/null +++ b/src/appmixer/hubspotmcp/core/CallToolAI/component.json @@ -0,0 +1,101 @@ +{ + "name": "appmixer.hubspotmcp.core.CallToolAI", + "description": "AI-powered tool calling for HubSpot MCP. Send a natural language prompt and an AI agent will select the appropriate tool, provide the arguments, and execute it.", + "auth": { + "service": "appmixer:hubspotmcp" + }, + "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 HubSpot data. Example: 'Find all contacts with email ending in @acme.com'" + }, + "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,PHN2ZyBoZWlnaHQ9IjI1MDAiIHZpZXdCb3g9IjYuMjA4NTYyODMgLjY0NDk4ODI0IDI0NC4yNjk0MzcxNyAyNTEuMjQ3MDExNzYiIHdpZHRoPSIyNTAwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxwYXRoIGQ9Im0xOTEuMzg1IDg1LjY5NHYtMjkuNTA2YTIyLjcyMiAyMi43MjIgMCAwIDAgMTMuMTAxLTIwLjQ4di0uNjc3YzAtMTIuNTQ5LTEwLjE3My0yMi43MjItMjIuNzIxLTIyLjcyMmgtLjY3OGMtMTIuNTQ5IDAtMjIuNzIyIDEwLjE3My0yMi43MjIgMjIuNzIydi42NzdhMjIuNzIyIDIyLjcyMiAwIDAgMCAxMy4xMDEgMjAuNDh2MjkuNTA2YTY0LjM0MiA2NC4zNDIgMCAwIDAtMzAuNTk0IDEzLjQ3bC04MC45MjItNjMuMDNjLjU3Ny0yLjA4My44NzgtNC4yMjUuOTEyLTYuMzc1YTI1LjYgMjUuNiAwIDEgMC0yNS42MzMgMjUuNTUgMjUuMzIzIDI1LjMyMyAwIDAgMCAxMi42MDctMy40M2w3OS42ODUgNjIuMDA3Yy0xNC42NSAyMi4xMzEtMTQuMjU4IDUwLjk3NC45ODcgNzIuN2wtMjQuMjM2IDI0LjI0M2MtMS45Ni0uNjI2LTQtLjk1OS02LjA1Ny0uOTg3LTExLjYwNy4wMS0yMS4wMSA5LjQyMy0yMS4wMDcgMjEuMDMuMDAzIDExLjYwNiA5LjQxMiAyMS4wMTQgMjEuMDE4IDIxLjAxNyAxMS42MDcuMDAzIDIxLjAyLTkuNCAyMS4wMy0yMS4wMDdhMjAuNzQ3IDIwLjc0NyAwIDAgMC0uOTg4LTYuMDU2bDIzLjk3Ni0yMy45ODVjMjEuNDIzIDE2LjQ5MiA1MC44NDYgMTcuOTEzIDczLjc1OSAzLjU2MiAyMi45MTItMTQuMzUyIDM0LjQ3NS00MS40NDYgMjguOTg1LTY3LjkxOC01LjQ5LTI2LjQ3My0yNi44NzMtNDYuNzM0LTUzLjYwMy01MC43OTJtLTkuOTM4IDk3LjA0NGEzMy4xNyAzMy4xNyAwIDEgMSAwLTY2LjMxNmMxNy44NS42MjUgMzIgMTUuMjcyIDMyLjAxIDMzLjEzNC4wMDggMTcuODYtMTQuMTI3IDMyLjUyMi0zMS45NzcgMzMuMTY1IiBmaWxsPSIjZmY3YTU5Ii8+PC9zdmc+" +} diff --git a/src/appmixer/hubspotmcp/core/ListTools/ListTools.js b/src/appmixer/hubspotmcp/core/ListTools/ListTools.js new file mode 100644 index 0000000000..0867931d09 --- /dev/null +++ b/src/appmixer/hubspotmcp/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/hubspotmcp/core/ListTools/component.json b/src/appmixer/hubspotmcp/core/ListTools/component.json new file mode 100644 index 0000000000..04a647ab2e --- /dev/null +++ b/src/appmixer/hubspotmcp/core/ListTools/component.json @@ -0,0 +1,28 @@ +{ + "name": "appmixer.hubspotmcp.core.ListTools", + "description": "Lists available tools from the HubSpot MCP server. Used as a helper component for dynamic tool selection and input generation.", + "private": true, + "auth": { + "service": "appmixer:hubspotmcp" + }, + "inPorts": [ + { + "name": "in", + "schema": { + "type": "object", + "properties": { + "toolName": { + "type": "string" + } + } + } + } + ], + "outPorts": [ + { + "name": "out", + "options": [] + } + ], + "icon": "data:image/svg+xml;base64,PHN2ZyBoZWlnaHQ9IjI1MDAiIHZpZXdCb3g9IjYuMjA4NTYyODMgLjY0NDk4ODI0IDI0NC4yNjk0MzcxNyAyNTEuMjQ3MDExNzYiIHdpZHRoPSIyNTAwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxwYXRoIGQ9Im0xOTEuMzg1IDg1LjY5NHYtMjkuNTA2YTIyLjcyMiAyMi43MjIgMCAwIDAgMTMuMTAxLTIwLjQ4di0uNjc3YzAtMTIuNTQ5LTEwLjE3My0yMi43MjItMjIuNzIxLTIyLjcyMmgtLjY3OGMtMTIuNTQ5IDAtMjIuNzIyIDEwLjE3My0yMi43MjIgMjIuNzIydi42NzdhMjIuNzIyIDIyLjcyMiAwIDAgMCAxMy4xMDEgMjAuNDh2MjkuNTA2YTY0LjM0MiA2NC4zNDIgMCAwIDAtMzAuNTk0IDEzLjQ3bC04MC45MjItNjMuMDNjLjU3Ny0yLjA4My44NzgtNC4yMjUuOTEyLTYuMzc1YTI1LjYgMjUuNiAwIDEgMC0yNS42MzMgMjUuNTUgMjUuMzIzIDI1LjMyMyAwIDAgMCAxMi42MDctMy40M2w3OS42ODUgNjIuMDA3Yy0xNC42NSAyMi4xMzEtMTQuMjU4IDUwLjk3NC45ODcgNzIuN2wtMjQuMjM2IDI0LjI0M2MtMS45Ni0uNjI2LTQtLjk1OS02LjA1Ny0uOTg3LTExLjYwNy4wMS0yMS4wMSA5LjQyMy0yMS4wMDcgMjEuMDMuMDAzIDExLjYwNiA5LjQxMiAyMS4wMTQgMjEuMDE4IDIxLjAxNyAxMS42MDcuMDAzIDIxLjAyLTkuNCAyMS4wMy0yMS4wMDdhMjAuNzQ3IDIwLjc0NyAwIDAgMC0uOTg4LTYuMDU2bDIzLjk3Ni0yMy45ODVjMjEuNDIzIDE2LjQ5MiA1MC44NDYgMTcuOTEzIDczLjc1OSAzLjU2MiAyMi45MTItMTQuMzUyIDM0LjQ3NS00MS40NDYgMjguOTg1LTY3LjkxOC01LjQ5LTI2LjQ3My0yNi44NzMtNDYuNzM0LTUzLjYwMy01MC43OTJtLTkuOTM4IDk3LjA0NGEzMy4xNyAzMy4xNyAwIDEgMSAwLTY2LjMxNmMxNy44NS42MjUgMzIgMTUuMjcyIDMyLjAxIDMzLjEzNC4wMDggMTcuODYtMTQuMTI3IDMyLjUyMi0zMS45NzcgMzMuMTY1IiBmaWxsPSIjZmY3YTU5Ii8+PC9zdmc+" +} diff --git a/src/appmixer/hubspotmcp/mcp-commons.js b/src/appmixer/hubspotmcp/mcp-commons.js new file mode 100644 index 0000000000..e4132fe9df --- /dev/null +++ b/src/appmixer/hubspotmcp/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 HubSpot'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.hubspot.com'; +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/hubspotmcp/package.json b/src/appmixer/hubspotmcp/package.json new file mode 100644 index 0000000000..a05d427012 --- /dev/null +++ b/src/appmixer/hubspotmcp/package.json @@ -0,0 +1,6 @@ +{ + "name": "appmixer.hubspotmcp", + "version": "1.0.0", + "private": true, + "description": "HubSpot MCP connector for Appmixer" +} diff --git a/src/appmixer/hubspotmcp/service.json b/src/appmixer/hubspotmcp/service.json new file mode 100644 index 0000000000..00f427cb7f --- /dev/null +++ b/src/appmixer/hubspotmcp/service.json @@ -0,0 +1,8 @@ +{ + "name": "appmixer.hubspotmcp", + "label": "HubSpot MCP", + "category": "applications", + "description": "HubSpot MCP (Model Context Protocol) integration. Enables AI-powered access to HubSpot CRM data through the official HubSpot remote MCP server.", + "icon": "data:image/svg+xml;base64,PHN2ZyBoZWlnaHQ9IjI1MDAiIHZpZXdCb3g9IjYuMjA4NTYyODMgLjY0NDk4ODI0IDI0NC4yNjk0MzcxNyAyNTEuMjQ3MDExNzYiIHdpZHRoPSIyNTAwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxwYXRoIGQ9Im0xOTEuMzg1IDg1LjY5NHYtMjkuNTA2YTIyLjcyMiAyMi43MjIgMCAwIDAgMTMuMTAxLTIwLjQ4di0uNjc3YzAtMTIuNTQ5LTEwLjE3My0yMi43MjItMjIuNzIxLTIyLjcyMmgtLjY3OGMtMTIuNTQ5IDAtMjIuNzIyIDEwLjE3My0yMi43MjIgMjIuNzIydi42NzdhMjIuNzIyIDIyLjcyMiAwIDAgMCAxMy4xMDEgMjAuNDh2MjkuNTA2YTY0LjM0MiA2NC4zNDIgMCAwIDAtMzAuNTk0IDEzLjQ3bC04MC45MjItNjMuMDNjLjU3Ny0yLjA4My44NzgtNC4yMjUuOTEyLTYuMzc1YTI1LjYgMjUuNiAwIDEgMC0yNS42MzMgMjUuNTUgMjUuMzIzIDI1LjMyMyAwIDAgMCAxMi42MDctMy40M2w3OS42ODUgNjIuMDA3Yy0xNC42NSAyMi4xMzEtMTQuMjU4IDUwLjk3NC45ODcgNzIuN2wtMjQuMjM2IDI0LjI0M2MtMS45Ni0uNjI2LTQtLjk1OS02LjA1Ny0uOTg3LTExLjYwNy4wMS0yMS4wMSA5LjQyMy0yMS4wMDcgMjEuMDMuMDAzIDExLjYwNiA5LjQxMiAyMS4wMTQgMjEuMDE4IDIxLjAxNyAxMS42MDcuMDAzIDIxLjAyLTkuNCAyMS4wMy0yMS4wMDdhMjAuNzQ3IDIwLjc0NyAwIDAgMC0uOTg4LTYuMDU2bDIzLjk3Ni0yMy45ODVjMjEuNDIzIDE2LjQ5MiA1MC44NDYgMTcuOTEzIDczLjc1OSAzLjU2MiAyMi45MTItMTQuMzUyIDM0LjQ3NS00MS40NDYgMjguOTg1LTY3LjkxOC01LjQ5LTI2LjQ3My0yNi44NzMtNDYuNzM0LTUzLjYwMy01MC43OTJtLTkuOTM4IDk3LjA0NGEzMy4xNyAzMy4xNyAwIDEgMSAwLTY2LjMxNmMxNy44NS42MjUgMzIgMTUuMjcyIDMyLjAxIDMzLjEzNC4wMDggMTcuODYtMTQuMTI3IDMyLjUyMi0zMS45NzcgMzMuMTY1IiBmaWxsPSIjZmY3YTU5Ii8+PC9zdmc+", + "version": "1.0.0" +}