asana-gate
Provides policy-enforced access to the Asana REST API, allowing AI agents to read, create, update, move, assign, complete, comment on, tag, and manage tasks and related Asana objects within scoped projects, sections, teams, portfolios, tags, and assignees. The server hides unauthorized objects and enforces declarative allow/deny rules, field restrictions, rate limits, and confirmation requirements.
This server does not currently have a deployable release on Glama. Follow the repository's setup instructions to install and configure it for your MCP client, including any required credentials and local dependencies.
asana-gate
A declarative access-control policy in front of the Asana REST API, served to AI agents over MCP.
MCP client ──MCP──▶ asana-gate ──policy──▶ Asana REST APIYou give an agent a Personal Access Token through asana-gate instead of directly. A policy file then decides, per project, section, task, tag or assignee, what the agent can see and do. Everything else does not exist for the agent.
Why
An Asana token grants everything its owner can do. Agents need much less: read this team, update tasks in that project, never touch the Done section, never delete. asana-gate turns such sentences into a YAML file and enforces it on every one of Asana's REST operations.
The policy is the only source of permissions. The token's rights are an upper bound; the default is deny.
Invisible, not forbidden. Objects the policy hides answer exactly like nonexistent gids (a 404 with Asana's own wording), and never appear in lists, searches, typeahead or events.
Every endpoint is covered. Every operation of Asana's OpenAPI definition is classified. A plain read, update or delete of one object (
GET /tasks/{task_gid}) is classified from its path; any other operation Asana adds fails the build until it is classified, and the weekly OpenAPI report names every new one for review, so none passes through unreviewed.MCP as specified. stdio and Streamable HTTP transports, no client-specific extensions.
Related MCP server: Asana MCP Server
Install
npm install -g asana-gateNode.js 24 or later is required.
npx asana-gate works too.
Quick start
Create a Personal Access Token and give it to asana-gate: exported for this shell, or saved in your home directory in a file only you can read (it is never printed).
export ASANA_ACCESS_TOKEN="…" # this shell only asana-gate token set --user # or keep it in ~/.asana-gate/token, typed unseen or piped inLet asana-gate look at your workspace and create the project's
.asana-gatedirectory: a policy skeleton, a settings file and a.gitignorethat keeps personal settings and the token file out of git.asana-gate initEdit
.asana-gate/policy.yaml, then validate it: names are resolved against Asana, every gid the rules name is checked to exist, and the token is checked.asana-gate checkServe it to your MCP client.
asana-gate mcpFor Claude Code,
asana-gate init --agent claude-codewrites the project's.mcp.json, a copy of the skill and a permission rule that keeps the token from the agent (see Agents). For any other MCP host, register the command; started inside the project directory,asana-gate mcpfinds the policy and the token by itself, and-Cpoints it at the project when the host starts it elsewhere:{ "mcpServers": { "asana-gate": { "command": "npx", "args": ["-y", "asana-gate", "mcp", "-C", "/path/to/project"] } } }
Policy
A policy names one workspace and lists rules.
Each rule has a scope (where), the actions it allows or denies there, and optionally constraints on the requests made there.
version: 1
workspace: "1200000000000001" # a gid or a name
rules:
# No `where`: the whole workspace. Read everything.
- allow: [read]
# Everything a team owns is writable (create and update, no delete).
- where: { team: "Platform" }
allow: [write]
# Full task work in one project, plus comments and tags.
- where: { project: "1200000000000123" }
allow: ["task:*", story:create, tag:apply]
# ...but its Done section is frozen (still visible; deny: ["*"] would hide it).
- where: { project: "1200000000000123", section: "Done" }
deny: [modify]
# Tasks tagged ai-ok may be updated wherever they are already visible.
- where: { tag: "ai-ok" }
allow: [task:update]
# Read a project, and update only these task fields in it.
- where: { project: "Design System" }
allow: [read]
fields: { task: [completed, assignee, due_on] }
# Tasks created here get a prefix and a tag, must have a due date,
# are capped at 20 an hour, and are deleted only after a person confirms.
- where: { project: "AI backlog" }
allow: ["task:*"]
require: { task: { due_on: true } }
apply: { task: { name: { prefix: "[AI] " }, tags: { add: [ai-generated] } } }
limit: { "task:create": { max: 20, per: 1h } }
confirm: [task:delete]
# Hide a project completely.
- where: { project: "Payroll" }
deny: ["*"]Names are resolved to gids when the policy loads; an unknown or ambiguous name is an error. Gids always work. Every scope key accepts one value or a list (any of them matches).
Scopes
Key | Matches | Level |
(none) | the whole workspace | 0 |
| the team and every project in it | 1 |
| the portfolio, its nested portfolios, and every project in them | 1 |
| tasks that belong to no project (and inherit none from a parent task) | 1 |
| the project and everything in it | 2 |
| a section of the | 3 |
| a task (gid) and all of its subtasks, recursively | 4 |
| exactly that task (gid) | 5 |
| filter: tasks carrying the tag | + |
| filter: tasks assigned to | + |
| filter: tasks whose field (by name or gid) holds one of the values, e.g. | + |
| filter: tasks created by | + |
| filter: completed ( | + |
| filter: subtasks ( | + |
| filter: tasks whose name satisfies | + |
| filter: tasks whose due date has passed ( | + |
| filter: tasks due within a duration from now, such as | + |
| filter: tasks created within a duration, such as | + |
Hierarchy keys in one where are ANDed.
Filters (tag, assignee, custom_field and the state filters below them) are ANDed with the hierarchy keys and never make an object visible on their own: they narrow or widen what may be done with objects that another rule already lets the agent read, and they may hide objects (deny: [task:read]).
For a create request, the filters see the object the request would create: its name after apply, the token's user as its creator, its due date and so on.
Objects that are not tasks live at the nearest hierarchy level: stories, attachments, time entries and the like follow their task; sections, briefs, status updates, memberships, templates and webhooks follow their project, portfolio or goal; tags, users, custom field definitions and most administrative resources live at workspace level.
Actions
An action is <resource>:<verb>.
Groups and wildcards expand to concrete actions: read (every read), modify (every other verb, the complement of read), write (read plus every creating and modifying verb), admin (everything), * (same as admin), task:*, *:delete.
A rule takes part only for the actions it names, so a scope is made read-only with allow: [read] and deny: [modify] together: the deny keeps a broader allow elsewhere from reaching it, and unlike deny: ["*"] it leaves the objects visible.
Resource | Verbs |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
read is the prerequisite of every other verb: an object whose read is not allowed is invisible, whatever else a rule says.
Some verbs are implied by the request body: updating a task's completed needs task:complete, its assignee needs task:assign, a project's archived needs project:archive; every other member needs update.
Explicit-only actions move data out of Asana or out of asana-gate's control, so no group or wildcard grants them; a policy must spell them out: webhook:create, webhook:update, export:create, organization_export:create, organization_export:read, audit_log:read.
Asana restricts some operations further on its own side, and the gate passes its answer through: audit log events, workspace events, organization exports and graph exports are served only to a service account of an Enterprise-tier organization, task templates, allocations, rates and custom roles take a paid tier, and the early-access Agents endpoints answer only requests that carry the ai_teammate_actors flag, which asana.enable in the settings sends.
Where a policy restricts rather than grants (deny, confirm, limit), groups and wildcards do cover them, so deny: [modify] also refuses webhook:create there.
fields
fields on a rule grants <resource>:update limited to the listed members (plus the verb a member stands for, such as task:complete for completed).
An update that touches any other member is refused with a 403 naming the member.
It applies to updates only; creating an object is governed by create alone.
Constraints
Besides allowing and denying actions, a rule can constrain the requests made in its scope and the responses they get: what a member must look like, what is filled in, what may not be set, what is hidden, how often an action may run and what a person must confirm.
A rule may consist of constraints alone, without allow or deny; its where decides where they are in force.
rules:
- where: { project: "AI backlog" }
allow: ["task:*", story:create]
require:
task:
name: { prefix: "[AI] ", max_length: 80 }
due_on: { present: true, min: today, max: "+30d" }
custom_fields: { Owner: { in: [AI] } }
apply:
task:
name: { prefix: "[AI] " }
tags: { add: [ai-generated] }
custom_fields: { Owner: AI }
forbid: { task: [html_notes, followers] }
redact: { task: [notes] }
limit: { "task:create": { max: 20, per: 1h } }
confirm: [task:delete, "project:*"]Clause | Acts on | Meaning |
| create and update requests | per resource and member, what the value must satisfy; a request that does not is refused with a 400 naming every problem |
| create and update requests | per resource and member, what is filled in or rewritten before the request reaches Asana |
| create and update requests | per resource, members the request may not set |
| responses | per resource, members removed from every object the agent sees |
| actions | per action, at most |
| actions | actions a person must confirm before they run: 428 when nobody can be asked, 403 when they decline |
Members are the request members of Asana's API (name, due_on, assignee, projects, tags, …); a member Asana does not accept for the resource is an error when the policy loads.
custom_fields nests by field name or gid, and the values of an enum field may be option names or gids.
Names in in, not_in and apply values are resolved when the policy loads, like names in where: enum option names, tag names (tags), users (me or an email for assignee, followers, owner), projects and teams; a request may then send either the gid or the name, and apply sends the gid.
A requirement combines operators, all of which must hold:
Operator | Meaning |
| required when creating; cannot be cleared when updating |
| the string must start with, end with or contain the text |
| the string must, or must not, match the regular expression |
| bounds on the string's length |
| the value, or every element of a list, must (not) be one of these; references compare by gid or name |
| bounds on a number, or on a date: |
An application is a bare value (a default) or an object of operators:
Operator | Meaning |
| set when the member is absent or empty (on update, only when the request clears it) |
| always set, whatever the request says |
| added to the string unless already there |
| values a list member must contain |
When several rules apply to one object, every require holds, the most specific rule's apply wins per member, and forbid, redact and confirm add up.
A request is shaped in the order apply, forbid, require, so a default or a prefix can satisfy a requirement.
Limits count per rule and action inside one gate process, over a sliding window, and a refused request does not count; they are a brake on runaway agents, not an audit trail.
How a request is decided
The request is matched to one of Asana's operations, and the objects it touches are loaded from Asana to find their place: workspace, team, portfolios, project, section, parent tasks, tags, assignee, custom field values.
For each action the operation needs, the most specific matching rules decide. Specificity is the hierarchy level of the scope (see the table), then the number of filters; between rules of equal specificity,
denywins. A rule that mentions neither the action nor the scope does not take part. No matching rule means deny.A task multi-homed in several projects must be allowed in every one of them; if any of its projects hides it, the task is invisible.
A subtask with no project of its own takes its placement from the nearest ancestor that has one (up to five levels); failing that it is unhomed.
Invisible objects answer with Asana's "Not a recognized ID" 404. Visible objects on which the action is not allowed answer with a 403 whose message starts with
asana-gate:.A create or update request is then shaped by the constraints in force:
applycompletes it, and a member that breaksforbidorrequirerefuses it with a 400 naming every problem.A
limitthat is already full refuses with a 429; an action underconfirmruns only after a person confirms (428 when nobody can be asked, 403 when they decline). The order matters: a later refusal never reveals what an earlier one hides, nobody is asked to confirm a request that would be refused anyway, and a refused request does not count against a limit.Lists, searches, typeahead and events are filtered; nothing says how many items were removed (
GET /projects/{gid}/task_countsis the exception: it is passed through and counts the project's hidden tasks too). A filtered page is refilled from the following pages so that it stays as full as asked for, never fuller. Alimitorconfirmon a read action applies to lists of that resource as well, once per request.Every object in a response carries
allowed_actions, the actions the policy currently allows on it, so an agent can plan before acting; members underredactare removed. Nested references to hidden objects (a subtask'sparent,dependencies, a job'snew_task, …) are removed, and visible ones are redacted as their own place in the policy says.
Rule order in the file does not matter.
asana-gate explain <gid> shows how the policy treats one object, which rules decided each action, and the constraints in force on it.
MCP server
asana-gate mcp serves the curated tools the policy could ever allow (a read-only policy lists no writing tool), plus three generic ones; the policy is applied when a tool runs.
Claude Code loads tool definitions on demand once they grow large, so a long list costs little there.
Curated tools for everyday work:
asana_search_tasks,asana_list_tasks,asana_get_task,asana_create_task,asana_update_task,asana_create_comment,asana_move_task_to_section,asana_add_tag_to_task,asana_list_projects,asana_create_section,asana_typeahead,asana_get_meand more.asana_create_tasksandasana_update_taskstake up to 50 tasks at once: the gate checks each one, Asana runs them ten at a time, and the result holds one status per task.asana_requestcalls any REST operation (method,path,query,body) through the same policy; aPOST /batchthrough it takes up to 50 actions.asana_operationslists the operations with the action each one needs;asana_allowed_actionstells what the policy allows on one object.
Refusals come back as tool errors whose text is the JSON error body, so agents see the same wording a direct API client would.
Confirmations
When a rule's confirm covers the action, the server asks the person through MCP elicitation before the call runs; nothing reaches Asana until they say yes.
On a connection that negotiated the 2026-07-28 protocol revision the tool call answers with an input_required result carrying a form elicitation and a sealed requestState; the client shows it and resends the same call with the answer and the state, which the server checks before executing, so an answer counts only for the question this server asked.
On an older connection the server sends elicitation/create itself and waits for the answer.
A declined or cancelled confirmation refuses the call with a 403.
A client that does not implement elicitation cannot confirm, so the action does not run (428).
Over the HTTP transport an older client is served one request at a time and cannot receive elicitation/create, so confirmations there need a client on the 2026-07-28 revision.
Transports
asana-gate mcp # stdio (default)
asana-gate mcp --transport http # Streamable HTTP on http://127.0.0.1:3845/mcp
asana-gate mcp --transport http --host 0.0.0.0 --port 8080 --allowed-host gate.example.internalThe HTTP transport validates Host and Origin headers against loopback plus --allowed-host entries (DNS rebinding protection).
Binding to anything but loopback requires a bearer token in ASANA_GATE_MCP_TOKEN (or the variable named by --auth-token-env), which clients must send as Authorization: Bearer ….
CLI
asana-gate mcp Serve the policy-gated Asana API as an MCP server
asana-gate check Validate the settings, the token and the policy against Asana
asana-gate explain <gid> Show what the policy allows on one object, and why
asana-gate init Create the project's .asana-gate directory; --agent claude-code wires Claude Code
asana-gate token Show where the Asana token comes from, or save one (never printed)
asana-gate settings Show the settings in force and where each value comes from
asana-gate cache Show or clear the on-disk cache of workspace names
asana-gate <resource> <verb> … Operate Asana through the policy
asana-gate api <METHOD> <path> Call any Asana REST operation through the policyCommon options: -C <dir> (run as if started in <dir>), --policy <file> on the commands that read a policy (default: the project's .asana-gate/policy.yaml, see Configuration), --token-env <NAME> (default ASANA_ACCESS_TOKEN), --log-level silent|error|warn|info|debug.
check fails (exit code 1) on a problem, such as an object the policy names that no longer exists, and only warns about the rest.
init and explain read Asana without the policy's restrictions; they exist to write policies and are not reachable over MCP.
explain also prints the constraints in force on the object.
Operating Asana from a shell
Every curated MCP tool is also a <resource> <verb> command with the same inputs, so whatever an agent may do over MCP, a person may do from a shell under the same policy.
asana-gate task list --project 1200000000000123 --completed-since now
asana-gate task create --name "Write docs" --projects 1200000000000123 --due-on 2026-12-01
asana-gate task update 1200000000000456 --assignee me --completed
asana-gate task move 1200000000000456 --section 1200000000000789
asana-gate task comment 1200000000000456 --text "Done, see the attached diff."
asana-gate task delete 1200000000000456
asana-gate task create-many --tasks @tasks.json
asana-gate section create --project 1200000000000123 --name "Review"
asana-gate find design --type project
asana-gate api GET /tasks/1200000000000456 -q opt_fields=name,completed
asana-gate api POST /tasks -d '{"name": "Write docs", "projects": ["1200000000000123"]}'The object a verb acts on is a positional argument; everything else is a flag named after the API member (
--due-onfordue_on).Booleans are switches (
--completed,--no-completed); lists take comma-separated values or repeated flags;--custom-fields,--tasksand other objects take JSON, inline, from a file (@tasks.json, relative to-C) or from standard input (-);nullclears a member that allows it.Lists print as tables with a hint for the next page, objects as
key: valuelines;--jsonprints the response body as Asana returns it, whichapialways does.When the policy wants an action confirmed, the command asks on the terminal;
--yes(-y) confirms in scripts, and a non-interactive run without it is refused.Exit codes: 0 on success, 1 when Asana or the policy refuses the request, 2 for a usage error.
A
limitcounts inside one process, so it binds the MCP server; one-shot commands start from zero each time.asana-gate <resource> --helplists the verbs;asana-gate <resource> <verb> --helplists the flags, with what is required and what the choices are.
Logs go to stderr and, by default, to ~/.asana-gate/logs/asana-gate.log (log.file in the settings).
The token is never written to logs, errors, URLs or responses.
At info level the client also reports the API changes Asana announces in its Asana-Change response header.
Configuration
asana-gate keeps its files the way Claude Code does: a .asana-gate directory in the project, one in your home directory, and settings an administrator can manage.
Layer | Where | For |
Managed |
| An administrator; nothing overrides these |
Flags and environment |
| One invocation |
Project, personal |
| You, in this project (ignored by git) |
Project, shared |
| Everyone working in the project |
User |
| You, in every project |
Higher rows win.
The project is the nearest directory, from the working directory upwards, that holds .asana-gate; asana-gate init creates it, and asana-gate init --user sets up ~/.asana-gate instead.
ASANA_GATE_HOME moves the home directory.
asana-gate settings prints the settings in force and the file each value came from.
Every layer may set any of these members, all optional; relative paths are relative to the file that names them.
Member | Default | Meaning |
| the project's | The policy file |
|
| The environment variable holding the Personal Access Token (the name Asana's own client libraries use) |
| – | A command whose output is the token, such as |
| the project's | A file holding the token on its first line |
|
|
|
|
| A file log lines are also appended to, created readable by you alone ( |
|
| Whether name resolution keeps the workspace's structure on disk between runs |
|
| Where; one subdirectory per token, named by a hash of it |
|
| How long a cached listing is trusted |
|
| Concurrent GET requests to Asana (its limit) |
|
| Concurrent POST, PUT and DELETE requests (its limit) |
|
| Retries after a 429, or after a 5xx for anything but a POST, waiting as long as |
|
| The longest wait before a retry; a longer |
|
| Flags sent in the |
|
| Flags sent in the |
|
| The defaults of |
A JSON Schema ships as asana-gate/settings.schema.json; init writes a # yaml-language-server: $schema=… pointer so editors validate the file.
Project settings are trusted like the project's code, which is why token.command is not honored from them.
The token
The Personal Access Token is read from the first of these sources, and asana-gate token status says which one is in use (the token itself is never printed):
the environment variable named by
token.env(ASANA_ACCESS_TOKEN);the
token.commandof your own or managed settings;the file named by
token.file, else the project's.asana-gate/token, else~/.asana-gate/token.
asana-gate token set saves a token, typed unseen or piped in, to the file read first (token.file when set, else the project's .asana-gate/token, which it lists in .asana-gate/.gitignore); asana-gate token set --user saves it to ~/.asana-gate/token.
The files are created readable by you alone, and check warns when one is not.
A token file in the project is what hosts without environment variables read, such as Claude Cowork (see Agents).
Cache
Resolving the names in a policy (teams, projects, sections, tags, custom fields) means listing them, and that is the slow part of starting the gate.
With the cache on, those listings are kept under cache.dir in files only you can read and reused until cache.ttl passes; a name missing from a cached listing is looked up live before it counts as unknown, so a project created an hour ago still resolves.
The cache holds the names and gids of the workspace's structure, never tokens, tasks or comments; what the gate learns about objects while it runs stays in memory.
Where even that is too much, turn it off in any layer with cache.enabled: false, for one run with ASANA_GATE_CACHE=off, or for everyone with a managed setting.
asana-gate cache shows the cache and asana-gate cache clear deletes every token's listings in it (other files in cache.dir stay).
Concurrency
The gate sends independent requests at the same time, up to Asana's own limits of 50 reads and 15 writes in flight per token (lower if concurrency says so): a page of tasks is checked with one lookup per task at once, the objects a write names are loaded together, and the actions of a POST /batch are planned together.
When Asana answers 429, the request waits the seconds its Retry-After header names (cut to retries.maxWait) and tries again, up to retries.max times, so running at the limit costs nothing but the wait; a 5xx is retried the same way after 1, 2, 4… seconds, or longer when its Retry-After asks, unless the request is a POST: Asana may have run it before failing, and a POST sent twice can create or add twice, so its 5xx is returned as it is.
An agent that needs many objects at once should send one POST /batch with up to ten actions rather than ten calls; the gate checks each action and Asana runs them as one request.
Noticing failures
The gate degrades rather than stops when its own files fail: a cache directory that cannot be written is left alone after one warning and names are read from Asana each time, and a log file that cannot be opened leaves warnings on stderr only. Three things make such a failure visible:
the warning itself, on stderr and in the log file (
~/.asana-gate/logs/asana-gate.logby default), where the MCP server's warnings land even when nobody watches its stderr;asana-gate check, which probes the cache and log directories, the token file's permissions and every object the policy names, and fails on what is broken;asana-gate cache, which shows whether the cache directory is writable.
Keeping up with Asana's API
The repository holds structural snapshots of Asana's public OpenAPI definition: every operation with its parameters and body members (src/catalog/operations.gen.ts) and every response schema as property names (src/catalog/schemas.gen.ts).
Wording is deliberately left out, so the snapshots change only when the API changes.
A weekly workflow refreshes them and opens a pull request whose description, produced by scripts/openapi-report.ts, lists the added, removed, changed and deprecated operations, the changed response fields, and what asana-gate must do about them.
CI fails on that pull request until every new operation is classified and every field the gate reads still exists; a new plain read, update or delete of one object is classified from its path, and the report lists it with its action to confirm.
Locally:
pnpm sync-openapi && pnpm format && node scripts/openapi-report.tsThe definition carries no wording, and some of what matters is written only on developers.asana.com: the opt-in flag of an early-access endpoint, a deprecation the definition does not mark, an access restriction.
A second weekly workflow reads the Markdown source of every page the site's index lists (llms.txt, then <page>.md) and lists, from the updatedAt of their front matter, the pages updated during the week, as a comment on one issue; pnpm docs-updates prints the list locally (--days widens the window).
Asana itself announces a change with an Asana-Change response header, which the gate logs once per change, and rolls it out over a period during which the Asana-Enable and Asana-Disable request headers choose the behaviour.
The asana.enable and asana.disable settings send those headers on every request, so a change can be taken early or kept off until its end date; asana.enable is also where an early-access opt-in goes, such as ai_teammate_actors for the Agents endpoints, which Asana refuses without it.
Agents
The skill
skills/asana-gate is an Agent Skill that teaches an agent how asana-gate answers, how to work through its tools and commands, and how to write a policy.
It is generated from this README and the tool definitions (pnpm generate-skill), and a test fails when the committed copy drifts.
npx skills add sincekmori/asana-gate installs it for the agents on your machine; Claude Code also reads the copy that init --agent claude-code puts in the project, and Claude Cowork takes it as a zipped upload under Customize > Skills.
Claude Code
asana-gate init --agent claude-code makes a repository use the gate without anyone wiring it by hand:
.mcp.jsongains the project-scopedasana-gateserver (npx -y asana-gate@<this version> mcp), which Claude Code asks each person to approve once;.claude/skills/asana-gatereceives a copy of the skill, which Claude Code loads by itself;checkwarns when the copy is from another version;.claude/settings.jsongainsRead(./.asana-gate/token)inpermissions.deny, so the agent cannot read the token file (Claude Code applies the rule tocatand the other file-reading commands as well).
Commit those with the policy, then say in CLAUDE.md what the command prints:
## Asana
Tasks for this repository live in the Asana project "<project>", reached only through asana-gate:
the `asana-gate` MCP server in .mcp.json, governed by .asana-gate/policy.yaml.
Read .claude/skills/asana-gate/SKILL.md before touching Asana.
Never call the Asana API any other way, and never read .asana-gate/token.Each person saves their own token with asana-gate token set (or exports it), and the policy, reviewed like code, is the whole of what the agent may do.
The copy of the skill keeps itself current: whenever asana-gate mcp starts in the project with another version than the copy carries, it rewrites the copy, so an upgrade of the pin in .mcp.json shows up as a change to commit.
The Claude Code extension for VS Code reads the same .mcp.json.
VS Code, Cursor and other agents
asana-gate init --agent vscode and --agent cursor (repeatable, and combinable with claude-code) do the same for those editors: the server goes into .vscode/mcp.json (under servers) or .cursor/mcp.json (under mcpServers), and the skill into .agents/skills/asana-gate, the cross-agent directory that VS Code, Cursor and Codex all read.
The command prints the paragraph for .github/copilot-instructions.md or AGENTS.md.
Neither editor can be told to keep its agent from reading a file in the project, so with them keep the token out of it: asana-gate token set --user, or the environment variable.
For Codex, the skill copy is enough on the file side; register the server with codex mcp add asana-gate -- npx -y asana-gate@<version> mcp.
Hosts without MCP
Where an MCP server cannot be used, such as Claude Cowork under a policy that disables local servers, the skill works through the command line: every tool is an asana-gate <resource> <verb> command with the same inputs under the same policy.
Such a host sees only the folder it was given, so keep everything in it: .asana-gate/policy.yaml and settings.yaml as usual, and the token in .asana-gate/token, which asana-gate token set writes and .gitignore keeps out of git.
The commands need Node.js 24 or later and network access to app.asana.com (and to the npm registry when they run through npx); asana-gate check from the folder confirms all of it in one go.
Notes from live verification
asana-gate was exercised against the real API. Some behaviors worth knowing:
Asana's 404 for a well-formed unknown gid is
task: Not a recognized ID: …; asana-gate uses the same wording for invisible objects.POST /tasksneedsprojects,parentorworkspace;membershipsalone is rejected, so theasana_create_tasktool sendsprojectstogether withmembershipswhen a section is given.POST /sections/{gid}/addTaskadds a task from another project to the section's project; asana-gate therefore requirestask:createthere as well astask:move.A task's
parentcannot be changed throughPUT /tasks/{gid}; usesetParent.Batch actions carry query parameters in
data, not inrelative_path.The Search API, custom fields, portfolios, goals and task dependencies are unavailable on free workspaces (402); the gate passes such responses through.
Unknown
opt_fieldsare ignored by Asana, so the fields the gate adds to requests never break them.With
opt_fields, Asana answers thegidand the selected members only: a reference selected alone comes back as{ gid, resource_type }, and one with a selection inside it (parent.name) as{ gid, name }, without itsresource_type. The gate therefore also selectsresource_typeinside every member its fields reach into, to recognize and check nested references, and cuts aGETby gid back to the caller's fields before answering.Nested selections reach into referenced objects, including on typeahead results:
memberships.project.teamgives a task's team with the task.Custom fields, custom types, time periods, time tracking categories and out-of-office entries carry no
workspacemember, so the gate places them in the governed workspace; with a token that belongs to several workspaces, rules at workspace level also govern such objects of the other workspaces when an agent names their gids.PUT /enum_options/{gid}is checked at workspace level only (Asana offers no way to read an enum option).
Library
The package also exports the building blocks for other hosts.
import { AsanaClient, AsanaNameResolver, compilePolicy, Gate, loadPolicyFile } from "asana-gate";
const client = new AsanaClient({ token: process.env.ASANA_ACCESS_TOKEN ?? "" });
const policy = await compilePolicy(
await loadPolicyFile("asana-gate.yaml"),
new AsanaNameResolver(client),
);
const gate = new Gate({ client, policy });
const response = await gate.execute({
method: "GET",
path: "/projects/1200000000000123/tasks",
query: { limit: "20" },
});
// response.status, response.body — shaped exactly like Asana's, filtered and decorated with allowed_actionsloadSettings({ cwd, env }) reads the same layered settings the command line uses.
JSON Schemas for policy and settings files ship as asana-gate/schema.json and asana-gate/settings.schema.json; point "$schema" at them for editor validation.
Development
pnpm install
pnpm build # tsdown (incl. publint + attw)
pnpm test # vitest
pnpm typecheck # tsc --noEmit
pnpm lint # oxlint --type-aware
pnpm format # oxfmt --write .
pnpm sync-openapi # refresh the OpenAPI snapshots
pnpm docs-updates # list the pages of developers.asana.com updated in the last week
pnpm generate-schema # regenerate schema.json and settings.schema.json
pnpm generate-skill # regenerate the skill from the README and the tool definitionsVersioning and publishing use Changesets: pnpm changeset describes a change, and merging to main opens a Version Packages PR that publishes to npm when merged.
License
This server cannot be deployed
Maintenance
Related MCP Connectors
Task management for people and AI agents, with scoped OAuth access to issues, projects, and docs.
Task management for people and AI agents, with scoped OAuth access to issues, projects, and docs.
Credential broker for AI agents: scoped, revocable API access with policy enforcement and audit.
Supervised API-write gateway for AI agents with policy, human approval and execution receipts.
Related MCP Servers
- AlicenseNot gradedqualityDmaintenanceProvides a standardized interface for interacting with Asana's tools and services through a unified API, enabling AI assistants to manage tasks, projects, and workflows.MIT
- AlicenseAqualityDmaintenanceEnables AI assistants to fully interact with Asana, including creating and managing tasks, projects, time tracking, and search, all through natural language.8614 npm1MIT
- AlicenseBqualityCmaintenanceMCP server for the Asana API that allows AI agents to read and optionally write to Asana tasks, comments, and custom fields, with tiered access controls and no delete tools.8284 npmMIT
- AlicenseNot gradedqualityCmaintenanceEnables to interact with Asana through Claude, with read and write tools for tasks, projects, tags, and custom fields, with no destructive operations.284 npmMIT