Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions src/appmixer/hubspotmcp/auth.js
Original file line number Diff line number Diff line change
@@ -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'
}
}
}
};
9 changes: 9 additions & 0 deletions src/appmixer/hubspotmcp/bundle.json
Original file line number Diff line number Diff line change
@@ -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."
]
}
}
57 changes: 57 additions & 0 deletions src/appmixer/hubspotmcp/core/CallTool/CallTool.js
Original file line number Diff line number Diff line change
@@ -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');
}
};
77 changes: 77 additions & 0 deletions src/appmixer/hubspotmcp/core/CallTool/component.json
Original file line number Diff line number Diff line change
@@ -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+"
}
151 changes: 151 additions & 0 deletions src/appmixer/hubspotmcp/core/CallToolAI/CallToolAI.js
Original file line number Diff line number Diff line change
@@ -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;

Check failure on line 26 in src/appmixer/hubspotmcp/core/CallToolAI/CallToolAI.js

View workflow job for this annotation

GitHub Actions / build

'additionalContext' is assigned a value but never used
const maxSteps = context.properties.maxSteps || 3;

Check failure on line 27 in src/appmixer/hubspotmcp/core/CallToolAI/CallToolAI.js

View workflow job for this annotation

GitHub Actions / build

'maxSteps' is assigned a value but never used

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 => ({

Check failure on line 38 in src/appmixer/hubspotmcp/core/CallToolAI/CallToolAI.js

View workflow job for this annotation

GitHub Actions / build

'toolDescriptions' is assigned a value but never used
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;
}
Loading
Loading