Guide · Productivity & Project Management Integrations
MCP Server Asana — GIDs, opt_fields, task sections, dependencies, webhook HMAC
Five Asana API behaviours surprise MCP tool authors: by default, Asana returns only gid and name for every resource — you must explicitly request every field you need via opt_fields or your tool will work but return near-empty objects; Asana tasks don't have a "status" field — tasks are "complete" or not, and their workflow stage is represented by which project section they belong to, requiring separate section membership API calls; due_on and due_at are separate, mutually-exclusive fields — setting due_at (datetime) silently overwrites due_on (date), and vice versa; creating a subtask requires the parent field at creation time — there's no reparenting API on task create, and the endpoint is POST /tasks with a parent body field, not a nested /tasks/{gid}/subtasks POST; and task dependencies are added via a dedicated mutation endpoint, not the task update endpoint.
TL;DR
Always include opt_fields in every Asana request listing the specific fields you need. Tasks have no "status" — track workflow stage via project sections. Use due_on for date-only deadlines (YYYY-MM-DD) and due_at for precise datetime deadlines (ISO 8601) — never set both. Add task dependencies via POST /tasks/{gid}/addDependencies. Verify webhooks with HMAC-SHA256 on the X-Hook-Signature header; Asana also sends a handshake X-Hook-Secret header on the first delivery that you must echo back.
Client setup and opt_fields
Asana provides an official Node.js client library. Personal access tokens are the simplest auth for MCP tools — generate one at app.asana.com/0/my-apps. The most impactful pattern to learn immediately is opt_fields: Asana's sparse fieldset parameter that controls which fields are returned. Without it, you get GID and name only — every other property (assignee, due date, notes, custom fields) returns as null or is absent.
import Asana from 'asana';
import { z } from 'zod';
// Module-level client — one connection pool
const asana = Asana.ApiClient.instance;
asana.authentications['token'].accessToken = process.env.ASANA_TOKEN!;
const tasksApi = new Asana.TasksApi();
const projectsApi = new Asana.ProjectsApi();
const sectionsApi = new Asana.SectionsApi();
const usersApi = new Asana.UsersApi();
// opt_fields: comma-separated list of dot-path field references
// Without this, response contains only { gid, name }
const TASK_OPT_FIELDS = [
'name',
'notes',
'completed',
'assignee',
'assignee.name',
'assignee.email',
'due_on',
'due_at',
'start_on',
'memberships.project.name',
'memberships.section.name',
'parent',
'parent.name',
'custom_fields',
'custom_fields.name',
'custom_fields.display_value',
'tags',
'tags.name',
'num_subtasks',
].join(',');
server.tool(
'asana_get_task',
{ task_gid: z.string().min(1).describe('Task GID (the numeric string ID)') },
async ({ task_gid }) => {
const task = await tasksApi.getTask(task_gid, {
opt_fields: TASK_OPT_FIELDS,
});
return {
content: [{ type: 'text', text: JSON.stringify(task.data) }],
};
}
);
server.tool(
'asana_list_project_tasks',
{
project_gid: z.string(),
completed: z.boolean().optional().describe('Filter by completion; omit for all tasks'),
limit: z.number().int().min(1).max(100).default(50),
offset: z.string().optional().describe('Pagination offset token from previous response'),
},
async ({ project_gid, completed, limit, offset }) => {
const params: Record<string, unknown> = {
project: project_gid,
opt_fields: TASK_OPT_FIELDS,
limit,
};
if (completed !== undefined) params.completed = completed;
if (offset) params.offset = offset;
const result = await tasksApi.getTasksForProject(project_gid, params);
return {
content: [{
type: 'text',
text: JSON.stringify({
tasks: result.data,
next_page: result.next_page, // { offset, path } or null
}),
}],
};
}
);
Creating tasks, subtasks, and setting due dates correctly
Tasks in Asana belong to a workspace or a project — you must supply exactly one of workspace, projects (an array), or parent (for subtasks). Providing both workspace and projects returns a 400. For subtasks, the parent task's GID goes in the parent body field. The subtask is automatically added to the parent's workspace but is not added to any project unless you explicitly specify projects.
server.tool(
'asana_create_task',
{
project_gid: z.string().describe('Project GID to add task to'),
name: z.string().min(1).max(256),
notes: z.string().max(10_000).optional(),
assignee_gid: z.string().optional(),
due_on: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional()
.describe('Date-only due date YYYY-MM-DD (no time)'),
due_at: z.string().datetime().optional()
.describe('Precise due datetime ISO 8601 — do NOT set both due_on and due_at'),
section_gid: z.string().optional().describe('Section within the project to place task in'),
},
async ({ project_gid, name, notes, assignee_gid, due_on, due_at, section_gid }) => {
if (due_on && due_at) {
return {
content: [{ type: 'text', text: 'Error: set either due_on or due_at, not both.' }],
isError: true,
};
}
const taskBody: Record<string, unknown> = {
name,
projects: [project_gid], // assigns task to this project
};
if (notes) taskBody.notes = notes;
if (assignee_gid) taskBody.assignee = assignee_gid;
if (due_on) taskBody.due_on = due_on;
if (due_at) taskBody.due_at = due_at;
const created = await tasksApi.createTask(
{ data: taskBody },
{ opt_fields: 'gid,name,due_on,due_at,assignee.name,completed' }
);
// Place in section if specified — addTaskForSection is a separate API call
if (section_gid && created.data?.gid) {
await sectionsApi.addTaskForSection(
section_gid,
{ data: { task: created.data.gid } }
);
}
return {
content: [{ type: 'text', text: JSON.stringify(created.data) }],
};
}
);
// Create a subtask under a parent task
server.tool(
'asana_create_subtask',
{
parent_task_gid: z.string().describe('GID of the parent task'),
name: z.string().min(1).max(256),
notes: z.string().optional(),
assignee_gid: z.string().optional(),
due_on: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
},
async ({ parent_task_gid, name, notes, assignee_gid, due_on }) => {
// POST /tasks with parent field — NOT /tasks/{gid}/subtasks
const subtaskBody: Record<string, unknown> = {
name,
parent: parent_task_gid,
};
if (notes) subtaskBody.notes = notes;
if (assignee_gid) subtaskBody.assignee = assignee_gid;
if (due_on) subtaskBody.due_on = due_on;
const created = await tasksApi.createTask(
{ data: subtaskBody },
{ opt_fields: 'gid,name,parent.name,due_on,completed' }
);
return {
content: [{ type: 'text', text: JSON.stringify(created.data) }],
};
}
);
Task sections and workflow state
Asana has no "status" field on tasks. Workflow stage is represented by which section within a project the task belongs to. Sections are ordered columns or categories in a project (e.g., "Backlog", "In Progress", "Done"). To query a task's current stage, read its memberships array — each membership contains a project and a section. To move a task to a different section, call addTaskForSection on the target section.
server.tool(
'asana_move_task_to_section',
{
task_gid: z.string(),
section_gid: z.string().describe('GID of the target section within the project'),
},
async ({ task_gid, section_gid }) => {
// Moves the task to this section — removes it from its current section in the same project
await sectionsApi.addTaskForSection(
section_gid,
{ data: { task: task_gid } }
);
return {
content: [{ type: 'text', text: JSON.stringify({ moved: true, section_gid, task_gid }) }],
};
}
);
server.tool(
'asana_list_project_sections',
{ project_gid: z.string() },
async ({ project_gid }) => {
const sections = await sectionsApi.getSectionsForProject(project_gid, {
opt_fields: 'gid,name,created_at',
});
// Sections are returned in display order (top to bottom in board view)
return {
content: [{
type: 'text',
text: JSON.stringify(sections.data?.map(s => ({ gid: s.gid, name: s.name }))),
}],
};
}
);
// Query a task's current section membership across all its projects
server.tool(
'asana_get_task_section',
{ task_gid: z.string() },
async ({ task_gid }) => {
const task = await tasksApi.getTask(task_gid, {
opt_fields: 'memberships.project.gid,memberships.project.name,memberships.section.gid,memberships.section.name',
});
const memberships = (task.data as any).memberships ?? [];
return {
content: [{
type: 'text',
text: JSON.stringify(memberships.map((m: any) => ({
project: { gid: m.project?.gid, name: m.project?.name },
section: { gid: m.section?.gid, name: m.section?.name },
}))),
}],
};
}
);
Task dependencies and webhook HMAC verification
Task dependencies (blocking relationships) are managed via dedicated endpoints, not the task update endpoint. A dependency means "task A cannot start until task B is complete". The addDependenciesForTask endpoint takes the blocking task's GID in a dependencies array body.
server.tool(
'asana_add_dependency',
{
task_gid: z.string().describe('Task that is blocked'),
dependency_gid: z.string().describe('Task that must complete first (the blocker)'),
},
async ({ task_gid, dependency_gid }) => {
await tasksApi.addDependenciesForTask(
task_gid,
{ data: { dependencies: [dependency_gid] } }
);
return {
content: [{ type: 'text', text: JSON.stringify({ added: true, task_gid, dependency_gid }) }],
};
}
);
// Asana webhook HMAC verification
// On first delivery, Asana sends X-Hook-Secret — you MUST echo it back in the response header
// Subsequent deliveries use X-Hook-Signature (HMAC-SHA256 of body with the secret)
import crypto from 'crypto';
import type { IncomingMessage, ServerResponse } from 'http';
const webhookSecretStore = new Map<string, string>(); // hookGid → secret
async function asanaWebhookHandler(req: IncomingMessage, res: ServerResponse) {
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(chunk as Buffer);
const rawBody = Buffer.concat(chunks);
// HANDSHAKE: first delivery sends X-Hook-Secret; echo it back to activate the webhook
const hookSecret = req.headers['x-hook-secret'] as string | undefined;
if (hookSecret) {
webhookSecretStore.set('default', hookSecret); // persist this secret for future verifications
res.setHeader('X-Hook-Secret', hookSecret);
res.writeHead(200).end();
return;
}
// VERIFICATION: subsequent deliveries include X-Hook-Signature
const signature = req.headers['x-hook-signature'] as string | undefined;
const secret = webhookSecretStore.get('default');
if (!signature || !secret) {
res.writeHead(403).end('Missing signature or no known secret');
return;
}
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(signature, 'hex'), Buffer.from(expected, 'hex'))) {
res.writeHead(401).end('Signature mismatch');
return;
}
const payload = JSON.parse(rawBody.toString()) as {
events: Array<{
action: string; // 'added' | 'changed' | 'removed' | 'deleted' | 'undeleted'
resource: { gid: string; resource_type: string; resource_subtype?: string };
parent?: { gid: string; resource_type: string };
created_at: string;
}>;
};
// Asana webhooks batch events — process each one
for (const event of payload.events) {
if (event.resource.resource_type === 'task') {
if (event.action === 'added') {
console.log(`New task: ${event.resource.gid}`);
} else if (event.action === 'changed') {
console.log(`Task changed: ${event.resource.gid}`);
}
}
}
res.writeHead(200).end();
}
The X-Hook-Secret handshake is a one-time event that happens when the webhook is first created. Store the secret durably (database, environment variable) — if your server restarts and you lose it, you can no longer verify future deliveries and must delete and re-create the webhook. Asana retries unacknowledged webhooks (non-200 responses) with exponential backoff up to 8 times over roughly 48 hours before marking the webhook inactive.