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
116 changes: 116 additions & 0 deletions src/appmixer/asanamcp/auth.js
Original file line number Diff line number Diff line change
@@ -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}}'
}
}
};
}
};
9 changes: 9 additions & 0 deletions src/appmixer/asanamcp/bundle.json
Original file line number Diff line number Diff line change
@@ -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."
]
}
}
57 changes: 57 additions & 0 deletions src/appmixer/asanamcp/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/asanamcp/core/CallTool/component.json
Original file line number Diff line number Diff line change
@@ -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=="
}
135 changes: 135 additions & 0 deletions src/appmixer/asanamcp/core/CallToolAI/CallToolAI.js
Original file line number Diff line number Diff line change
@@ -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;

Check failure on line 23 in src/appmixer/asanamcp/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 24 in src/appmixer/asanamcp/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));

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;
}
Loading
Loading