Skip to content
Merged
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
16 changes: 13 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ The server runs on your machine over stdio. By default, it authenticates with an
> Behavior may change without notice, and there is no guarantee of continued availability or support.
> Use of this software is entirely at your own risk.

## Quick setup

1. Run the [installer](#install-or-update).
2. If you aren't already signed in, the installer checks for stored Asana credentials and
offers to [sign you in](#sign-in-to-asana) with a personal access token on the spot.
3. Verify the connection any time with [`doctor`](#check-the-connection).

## Requirements

- Node.js 22 or newer
Expand Down Expand Up @@ -38,7 +45,9 @@ The installer:
- installs it under `~/.asana/mcp`;
- detects Claude Code (`claude`), Claude Desktop (its installed application), Codex (`codex`),
Cursor (`cursor` or Cursor Agent's `agent`), and OpenCode (`opencode`);
- automatically configures every detected client as a user-level stdio MCP server.
- automatically configures every detected client as a user-level stdio MCP server;
- checks whether Asana authentication is already configured and, if not, offers to sign you in
interactively.

Run the same command again to update the existing installation. The executable path remains
`~/.asana/mcp/bin/asana-command-mcp`, so configured clients do not need a version-specific path.
Expand All @@ -48,8 +57,9 @@ having the `codex` command installed also covers ChatGPT Desktop; there is no se

With no flags (equivalent to `--all`), the installer configures every client it detects and
silently skips the rest — nothing is installed or configured for a client that isn't present, and
nothing prompts. To require one specific client and fail loudly if it's missing, select it
explicitly instead:
nothing prompts about which clients to configure. (The installer may still ask you to sign in to
Asana if you aren't already authenticated — see [Quick setup](#quick-setup).) To require one
specific client and fail loudly if it's missing, select it explicitly instead:

```sh
curl -fsSL https://github.com/Asana/command-mcp/releases/latest/download/install.sh \
Expand Down
37 changes: 35 additions & 2 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -523,7 +523,40 @@ else
info "No MCP clients were configured."
fi
info ""
info "Next, sign in to Asana:"
info " \"$executable\" auth login"

auth_configured=false
if "$executable" auth status >/dev/null 2>&1; then
auth_configured=true
fi

if [ "$auth_configured" = true ]; then
info "Asana sign-in: already configured."
elif [ -t 1 ] && [ -r /dev/tty ]; then
printf 'Asana is not signed in yet. Sign in now with a personal access token? [Y/n] ' >/dev/tty
answer=''
IFS= read -r answer </dev/tty || true
case "$answer" in
n | N | no | NO | No) ;;
*)
if "$executable" auth login </dev/tty; then
if "$executable" auth status >/dev/null 2>&1; then
info "Signed in to Asana."
auth_configured=true
else
info "Sign-in did not complete."
fi
else
info "Sign-in did not complete."
fi
;;
esac
fi

if [ "$auth_configured" = false ]; then
info ""
info "Next, sign in to Asana:"
info " \"$executable\" auth login"
info " (or \"$executable\" auth login --oauth to use OAuth instead)"
fi
info ""
info "Run this installer again at any time to update to the latest release."
18 changes: 16 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import { buildServices, type CommandServices } from "./services.js";
import type { UpdateChecker } from "./update_check.js";

export const CLI_USAGE =
"Usage: asana-command-mcp [doctor [TEAMSPACE_ID_OR_URL] | auth login [--oauth]]";
"Usage: asana-command-mcp [doctor [TEAMSPACE_ID_OR_URL] | auth login [--oauth] | auth status]";

type OutputWriter = {
write(data: string): unknown;
Expand Down Expand Up @@ -69,9 +69,23 @@ export async function runCli(options: RunCliOptions = {}): Promise<void> {
subcommandArgs.length === 2 &&
subcommandArgs[0] === "login" &&
subcommandArgs[1] === "--oauth";
if (!isPersonalAccessTokenLogin && !isOAuthLogin) {
const isStatus = subcommandArgs.length === 1 && subcommandArgs[0] === "status";
if (!isPersonalAccessTokenLogin && !isOAuthLogin && !isStatus) {
throw invalidCliUsage();
}
if (isStatus) {
const env = options.env ?? process.env;
const personalAccessTokenStore =
options.personalAccessTokenStore ?? createDefaultPersonalAccessTokenStore();
const personalAccessToken = await personalAccessTokenStore.load();
const oauthCredentialStore =
options.oauthCredentialStore ?? createDefaultOAuthCredentialStore();
const oauthCredentials =
personalAccessToken === null ? await oauthCredentialStore.load() : null;
const config = loadConfig(env, { personalAccessToken, oauthCredentials });
stdout.write(`Asana authentication is configured (${config.authentication.type}).\n`);
return;
}
if (isOAuthLogin) {
const env = options.env ?? process.env;
const credentialStore = options.oauthCredentialStore ?? createDefaultOAuthCredentialStore();
Expand Down
6 changes: 4 additions & 2 deletions tests/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,8 @@ describe("CLI", () => {
it("rejects incomplete auth subcommands before loading configuration", async () => {
await expect(runCli({ args: ["auth"], env: {} })).rejects.toMatchObject({
code: "invalid_input",
message: "Usage: asana-command-mcp [doctor [TEAMSPACE_ID_OR_URL] | auth login [--oauth]]",
message:
"Usage: asana-command-mcp [doctor [TEAMSPACE_ID_OR_URL] | auth login [--oauth] | auth status]",
});
});

Expand Down Expand Up @@ -310,7 +311,8 @@ describe("CLI", () => {
it("rejects unknown subcommands before loading configuration", async () => {
await expect(runCli({ args: ["serve"], env: {} })).rejects.toMatchObject({
code: "invalid_input",
message: "Usage: asana-command-mcp [doctor [TEAMSPACE_ID_OR_URL] | auth login [--oauth]]",
message:
"Usage: asana-command-mcp [doctor [TEAMSPACE_ID_OR_URL] | auth login [--oauth] | auth status]",
});
});

Expand Down
40 changes: 40 additions & 0 deletions tests/installer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,14 @@ if [ "\${1:-}" = "doctor" ]; then
printf '%s\\n' '{"error":{"code":"invalid_configuration","message":"Asana login is missing; run asana-command-mcp auth login"}}'
exit 1
fi
if [ "\${1:-}" = "auth" ] && [ "\${2:-}" = "status" ]; then
[ -f "$TEST_LOG/auth-configured" ]
exit $?
fi
if [ "\${1:-}" = "auth" ] && [ "\${2:-}" = "login" ]; then
: >"$TEST_LOG/auth-configured"
exit 0
fi
EOF
chmod +x "$prefix/bin/asana-command-mcp"
mkdir -p "$prefix/lib/node_modules/@asana/command-mcp"
Expand Down Expand Up @@ -219,12 +227,16 @@ function runInstaller(options: {
clients?: Client[];
includeNpm?: boolean;
claudeDesktopInstalled?: boolean;
authConfigured?: boolean;
}) {
const home = join(options.root, "home with spaces");
const assets = join(options.root, "assets");
const log = join(options.root, "log");
mkdirSync(home, { recursive: true });
mkdirSync(log, { recursive: true });
if (options.authConfigured === true) {
writeFileSync(join(log, "auth-configured"), "");
}
if (!existsSync(join(assets, "asana-command-mcp.tgz"))) {
createArchive(assets);
}
Expand Down Expand Up @@ -344,6 +356,34 @@ describe("install.sh", () => {
expect(result.stdout).toContain("No supported MCP clients were detected");
});

// The installer only prompts to sign in when stdin is a TTY (`[ -t 1 ] && [ -r /dev/tty ]`),
// matching the pre-existing `remove_old_package` prompt. spawnSync's piped stdio is never a
// TTY, so that interactive branch is not exercised here and is verified manually instead.
it("skips the sign-in prompt when Asana auth is already configured", () => {
const root = temporaryDirectory("command-installer-auth-configured");
const { result } = runInstaller({
root,
args: ["--no-config"],
authConfigured: true,
});

expect(result.status, result.stderr).toBe(0);
expect(result.stdout).toContain("Asana sign-in: already configured.");
expect(result.stdout).not.toContain("Next, sign in to Asana");
});

it("prints a non-interactive sign-in hint when Asana auth is not configured", () => {
const root = temporaryDirectory("command-installer-auth-missing");
const { result } = runInstaller({
root,
args: ["--no-config"],
});

expect(result.status, result.stderr).toBe(0);
expect(result.stdout).toContain("Next, sign in to Asana");
expect(result.stdout).toContain("auth login --oauth");
});

it("configures every detected client by default and skips undetected ones without prompting", () => {
const root = temporaryDirectory("command-installer-defaults");
const { home, log, result } = runInstaller({
Expand Down