npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@ai2aim.ai/mcp

v0.2.0

Published

MCP server wrapping the EMS Task Management System (TMS) REST API with browser-based OAuth2 + PKCE login.

Readme

TMS MCP server

An MCP server that lets Claude (Claude Code / claude.ai) create and manage tickets in the EMS Task Management System (TMS) — the in-house replacement for the Atlassian/Jira MCP integration. It wraps the TMS REST API (/api/v1/tasks …) and uses browser-based OAuth2 + PKCE for login (no API keys, no secrets stored in the agent).

Prerequisites

  • Node.js >= 22 (node --version). The server uses native fetch, node:crypto, and node:http.
  • A browser on the machine running the MCP server (login opens a consent page).
  • Network access to the target TMS environment.
  • The claude CLI on PATH (for the quick setup below).

Quick setup (recommended)

register.mjs builds the server and registers it with Claude Code and/or Cursor in one step. It runs on Windows and macOS, detects your OS, and derives every path from its own location — no hardcoded paths to edit.

node register.mjs                    # build dist/, then register `tms` with BOTH Claude Code and Cursor (env: local)
node register.mjs --target cursor    # Cursor only
node register.mjs --target claude    # Claude Code only

Options:

| Flag | Default | Purpose | |------|---------|---------| | --env <local\|dev\|staging\|prod> | local | Default environment baked into the registration. | | --target <claude\|cursor\|both> | both | Which client(s) to register with. | | --fresh | (off) | Reinstall node_modules before building. |

What it does: checks Node ≥ 22 (and claude, only when targeting Claude Code), runs npm install (if node_modules is missing or --fresh) + npm run build, then registers the server with each selected client:

  • Claude Code — removes any stale tms registration (user/local scope), then claude mcp add tms --scope user … with the env vars below, and prints claude mcp list.
  • Cursor — merges a tms entry into the global ~/.cursor/mcp.json (Windows: %USERPROFILE%\.cursor\mcp.json). Other servers in that file are left untouched; only the tms entry is overwritten. Cursor has no mcp add CLI, so this is the file equivalent.

After it finishes, restart the client(s) (config is read at startup) — in Claude Code confirm with /mcp, in Cursor check Settings → MCP — then run connect{ env: "local" } (see Connecting).

Prefer to wire it up by hand? See Manual setup.

Manual setup

Build

npm install
npm run build      # tsc -> dist/

This produces dist/index.js, the MCP entry point.

Register with Claude Code

Run from the repo root (use an absolute path if registering at user scope so it resolves from any working directory):

claude mcp add tms -- node ./dist/index.js

Optional environment variables (defaults shown):

| Var | Default | Purpose | |-----|---------|---------| | TMS_OAUTH_CLIENT_ID | ems-mcp-cli | OAuth public client id. Also used for the required X-Device-Signature: oauth2:<id> header — must match the backend seed. | | TMS_DEFAULT_ENV | dev | Default environment when a tool omits env. Never staging or prod. | | TMS_DEBUG | (unset) | Set to any value to enable verbose stderr debug logging. |

You can set these in the claude mcp add command, e.g.:

claude mcp add tms --env TMS_DEFAULT_ENV=dev --env TMS_OAUTH_CLIENT_ID=ems-mcp-cli -- node ./dist/index.js

Register with Cursor

Cursor reads MCP servers from a JSON config — globally at ~/.cursor/mcp.json (Windows: %USERPROFILE%\.cursor\mcp.json), or per-project at .cursor/mcp.json in the repo root. Add a tms entry under mcpServers (use an absolute path to dist/index.js):

{
  "mcpServers": {
    "tms": {
      "command": "node",
      "args": ["/absolute/path/to/ems-mcp/dist/index.js"],
      "env": {
        "TMS_DEFAULT_ENV": "dev",
        "TMS_OAUTH_CLIENT_ID": "ems-mcp-cli"
      }
    }
  }
}

On Windows, JSON-escape the backslashes, e.g. "args": ["C:\\Work\\web-development\\ems-mcp\\dist\\index.js"]. Then restart Cursor and confirm the server under Settings → MCP. (register.mjs writes exactly this entry for you — see Quick setup.)

Connecting (login + consent walkthrough)

Each tool accepts an optional env (prod | staging | dev | local). When omitted it uses TMS_DEFAULT_ENV (default dev).

  1. In a Claude session, call connect (optionally connect{ env: "dev" }).
  2. The server generates a PKCE pair + state, starts a temporary loopback listener on http://127.0.0.1:<random-port>/callback, and opens your browser to the frontend consent page for that environment. If the browser does not open automatically, the URL is printed to the server's stderr — open it manually.
  3. The consent page requires you to be logged in to the SPA (it round-trips through /login preserving the OAuth params). Log in if prompted.
  4. Pick your organization and review the requested scopes (tasks:read, tasks:write, comment:write, tag:write, link:write, offline_access), then Approve.
  5. The browser is redirected back to the loopback listener, which captures the authorization code, verifies state, exchanges the code for tokens, stores them per-environment, and reports "Connected". You can close the browser tab.

After connecting, the other tools work without re-prompting. The access token (15 min) is refreshed silently using the refresh token; the browser only re-opens when refresh fails, or to connect another environment or org.

⚠️ Selecting prod or staging is deliberate

prod and staging are never the default and the server will not silently fall back to them. Only pass env: "prod" or env: "staging" (in connect and subsequent tools) when you genuinely intend to act against that environment — prod is live production data, and staging may contain shared/production-like data.

Working across several organizations (local server)

A TMS token is bound to the one org picked on the consent page. The local server keeps one token per org, so someone in several orgs connects each once and then moves between them without a browser:

connect { env: "prod", org: "ai2aim" }    # browser: pick Ai2Aim
connect { env: "prod", org: "vacario" }   # browser: pick Vacario — Ai2Aim stays connected

create_task { org: "vacario", title: "…", description: "…" }   # this call only
use_org { org: "ai2aim" }                                        # change the default
list_tasks {}                                                    # -> active org (Ai2Aim)
  • Every env-scoped tool takes an optional org: the slug (ai2aim-inc), display name (Ai2Aim Inc) or organizationId of a connected org, case-insensitive. Without it the active org is used — the one most recently connected or picked with use_org. An explicit org does not change it.
  • An org that is not connected is an error listing the ones that are; the server never falls back to another org. With several connected and none active, calls without org fail the same way.
  • connect { org } is checked after login: if a different org was picked in the browser, nothing is saved.
  • Every successful result starts with org: <Name> [<slug>] (<env>) so it is clear where a write landed.
  • list_orgs shows what is connected; disconnect { org } forgets one org.
  • Token stores written by older versions (one token per env) are filed under their org on first use; no re-login is needed. tokens.json keeps tokens[env] in the old single-token shape, always a copy of the active org, with the org list under a separate orgs key, so older builds and other tools that read the file keep working and act in the active org.
  • Current backend limit: the backend keeps one MCP session per user across all orgs, so connecting a second org ends the first org's session until the backend scopes sessions per org. Until then, switching to an org whose session ended needs connect again.

Over HTTP (a hosted connector) the token fixes the org, so org is not advertised and the org tools are not exposed. Connect a separate connector per org there.

Tools

Every tool (except connect, server_info, update_server) accepts an optional env, and on the local server every env-scoped tool also accepts an optional org.

Auth & session

| Tool | Purpose | |------|---------| | connect | Browser OAuth login for an environment + org. The only tool that opens a browser. Run once per org. | | list_orgs | Connected orgs for an env (slug, name, id, active). Local only. | | use_org | Switch the active (default) org — instant, no browser. Local only. | | disconnect | Forget one org's tokens. Local only. | | whoami | Current authenticated user's profile (who the session acts as). |

Tasks

| Tool | Purpose | |------|---------| | create_task | Create a task. type/category/status/tags accept names (find-or-create) or ids; optional sprintId. collaboratorEmployeeIds attaches collaborators (requires assignedToEmployeeId, who stays the accountable owner). | | update_task | Update fields on a task by id. sprintId: null clears the sprint. | | get_task | Fetch a task by id (includes links). | | list_tasks | List/search tasks (filters + pagination). status accepts a coarse value, exact name, or id; sortOrder is case-insensitive. | | delete_task | Delete a task by id (soft-delete). | | get_task_history | A task's change history (paginated). | | get_task_activity | A task's activity feed — history + comments combined (paginated). | | get_time_spent | Time logged on a task. | | log_time | Set time spent on a task (hours + optional minutes). |

Comments

| Tool | Purpose | |------|---------| | add_comment | Add a comment (optional mentionsIds). | | list_comments | List a task's comments (paginated). | | edit_comment | Edit a comment by id. | | delete_comment | Delete a comment by id. |

Tags & links

| Tool | Purpose | |------|---------| | add_tag | Attach a tag (name find-or-create, or tagDefinitionId). | | list_tags | List the tags attached to a task. | | remove_tag | Remove a tag from a task (resolved against the task's own tags, or tagDefinitionId). | | link_tasks | Link two tasks (blocks/is_blocked_by/relates_to/duplicates/is_duplicated_by). | | list_links | List a task's links (filter by direction). | | unlink_tasks | Remove a link (linkId, or targetTaskId + linkType). |

Lookups

| Tool | Purpose | |------|---------| | list_task_types | List task types (id, key, label, isSystem). | | list_statuses | List statuses (id, normalized value, is_system, is_default). | | list_categories | List task categories (id, name). | | list_tag_definitions | List the org's tag definitions (org-wide; list_tags is per-task). | | list_employees | List/search employees — resolve a person → assignedToEmployeeId. | | list_sprints / get_sprint | List sprints (filter by state) / fetch one by id. |

Definition management

| Tool | Purpose | |------|---------| | create_category / update_category / delete_category | Manage task categories. | | create_tag_definition / update_tag_definition / delete_tag_definition | Manage org tag definitions. | | create_task_type / update_task_type / delete_task_type | Manage custom task types. | | create_status / update_status / delete_status | Manage custom workflow statuses (system statuses are read-only/undeletable). |

Server maintenance

| Tool | Purpose | |------|---------| | server_info | Report this server's version, git branch/commit, dirty flag, dist build time, node version. | | update_server | Self-update: git pull --ff-only → optional npm install ({install:true}) → rebuild. Reconnect/restart afterward to load the new build. |

Name → id resolution. The Tasks API takes ids, not names. The server resolves names with find-or-create per the Jira→TMS migration contract:

  • task type: find-or-create by key = slug(name), label = name (never collapsed into a system type);
  • category: find-or-create by name;
  • tag: find-or-create a tag definition by name, then use its id;
  • status: resolved to the exact status row by name, coarse enum, or id (no creation).

Permissions. OAuth scopes (tasks:read, tasks:write, …) gate the core /tasks routes. Tools that hit board/sprint/employee/definition routes are additionally subject to the connected user's RBAC — an org owner passes; other users get their normal per-permission access.

Remote (HTTP) transport

The server runs on stdio by default. Set TMS_TRANSPORT=http to serve MCP over Streamable HTTP instead, which is what a claude.ai custom connector needs.

| Var | Required | Purpose | |-----|----------|---------| | TMS_TRANSPORT | — | stdio (default) or http. | | TMS_PUBLIC_URL | yes, under http | Externally reachable origin, e.g. https://mcp.example.com. Used as the OAuth issuer and in the metadata documents. | | TMS_HTTP_ENV | no | The TMS environment this deployment is pinned to (default: TMS_DEFAULT_ENV). | | TMS_DEV_BASE_URL / TMS_DEV_WEB_ORIGIN | to use dev | API origin and frontend origin for the dev env. Not compiled in — see below. | | TMS_STAGING_BASE_URL / TMS_STAGING_WEB_ORIGIN | to use staging | Same, for staging. | | TMS_PORT | no | Listen port (default 8080). | | TMS_OAUTH_REDIRECT | no | terminate (default) or passthrough — see below. | | TMS_ALLOWED_REDIRECT_ORIGINS | no | Comma-separated origins a client may register a callback on. Defaults to https://claude.ai,https://claude.com,https://chatgpt.com. | | TMS_TRUST_PROXY | no | Number of reverse-proxy hops in front of this server (default 0, trusting nothing). Set it when behind a load balancer, or rate limiting will see the proxy's IP. | | TMS_TOOL_PROFILE | no | full (default) or readonly — see below. Applies to both transports. | | TMS_SESSION_MODE | no | stateful (default) or stateless. Stateless issues no session ids and returns JSON rather than SSE; required on Lambda. | | TMS_STATE_STORE | no | memory (default) or dynamodb. DynamoDB is what allows more than one instance. | | TMS_STATE_TABLE | with dynamodb | The DynamoDB table name. |

TMS_TRANSPORT=http TMS_HTTP_ENV=dev TMS_PUBLIC_URL=https://mcp.example.com   node ./dist/index.js

Register it under Settings -> Connectors -> Add custom connector with the /mcp URL. Point a first deployment at dev, not prod.

How it differs from stdio

  • The env is pinned per deployment. The env argument is dropped from the advertised tool schemas and ignored if passed, so one connector cannot be talked into reaching another environment.
  • Three tools are withheld: connect (drives a browser and the local token store), plus server_info and update_server, which shell out to git/npm against the checkout — meaningless, and a remote-exec surface, on a deployed host.
  • Nothing is persisted. Each request carries its own bearer token; there is no server-side token store. ~/.config/tms-mcp/tokens.json is a stdio-only concern.
  • A mid-session expiry cannot self-heal. The MCP client holds the refresh token, so the tool call fails with a "reconnect the connector" message and re-auth happens on the client's next token refresh.

OAuth

The server is the authorization server for MCP clients and proxies the exchanges to TMS. It registers clients dynamically itself, so TMS does not need to support dynamic client registration — every downstream client maps to the single upstream TMS_OAUTH_CLIENT_ID.

Which redirect mode to use

TMS_OAUTH_REDIRECT picks how the consent redirect comes back.

| | terminate (default) | passthrough | |---|---|---| | Upstream redirect_uri | {TMS_PUBLIC_URL}/oauth/callback | the MCP client's own callback | | TMS must allowlist | one stable URI, yours | the client's callback, on Anthropic's domain | | State held here | in-flight logins (10 min) + issued codes (5 min, single use) | none | | PKCE | client's validated here, a second pair used upstream | client's challenge forwarded; TMS validates | | Survives a restart | in-flight logins are dropped | yes | | Multi-instance | needs sticky routing or shared storage | works as-is |

Use terminate. The backend's isAllowedRedirectUri (src/utils/oauth2/redirect-uri.util.ts) requires an exact match against the client's registered redirect_uris, and relaxes matching only for http loopback hosts per RFC 8252. An https callback on a third party's domain can never satisfy that, so passthrough is undeployable against TMS as it stands. It is kept for a backend that explicitly registers the MCP client's own callback.

The one backend change still required

Whichever mode you use, the remote server's callback must be registered as an OAuth2 client. The existing ems-mcp-cli seed (db/seeders/20260619100200-seed-mcp-public-oauth2-client.js) only lists loopback URIs, which is right for the CLI and useless for a deployment.

Two changes are needed, both now written:

  1. ems-backend — seeder 20270910000000-seed-mcp-remote-oauth2-client.js registers a separate ems-mcp-remote public client, leaving the CLI's loopback registration untouched. Set MCP_REMOTE_REDIRECT_URIS to the deployment's callback (https://<host>/oauth/callback) and run npm run db:seed — or db:seed:force to re-converge after a hostname change. Unset means "no hosted MCP server here" and the seeder skips.
  2. employee-management-system — the consent page previously rejected non-loopback redirect URIs in the SPA, before the backend was asked. oauth-consent.tsx now performs only a syntactic check and leaves permission to GET /auth/oauth2/consent, which validates against the registered redirect_uris.

Then set TMS_OAUTH_CLIENT_ID=ems-mcp-remote on this deployment. That value is also the X-Device-Signature: oauth2:<id> suffix, so it must match the seed.

Why dev and staging hosts are not compiled in

This package is published to a public npm registry, and a registry tarball is plain text that anyone can download and read — nothing about publishing encrypts or obscures it. A hostname baked into the source is therefore a hostname handed to the world, permanently: unpublishing does not help, because mirrors copy the tarball within minutes.

prod and local stay in the source, because public DNS and loopback lose nothing by being there. dev and staging do not: their Function URLs were protected only by being unguessable, which publishing destroys. They are read from the environment instead, and selecting one of those envs without its two variables set is an error naming both, rather than a silent fallback.

The deployment stacks supply them (aws/cloudformation/*.yml), which is safe because this repository is private.

Why registrable redirect origins are restricted

Dynamic client registration is unauthenticated by design (RFC 7591), and the SDK's authorize handler will redirect to any URI a client has registered. Put together, an unrestricted store would mean anyone could register a client pointing at their own domain and have a consenting user's authorization code delivered there — a working account-takeover via consent phishing, since the consent screen shows a client name the attacker chose.

TMS cannot catch this. In terminate mode it only ever sees this server's own client id and callback, so its redirect_uris allowlist is satisfied and the final destination is decided here. That makes TMS_ALLOWED_REDIRECT_ORIGINS the security boundary, not a convenience: registration rejects any callback whose origin is not on it. Widen it only to origins you trust with your users' tokens.

(In passthrough mode the client's callback goes upstream, so TMS's allowlist would also reject it — one respect in which passthrough is the safer of the two, though it remains undeployable for the reason above.)

Scopes

TMS OAuth2 access tokens are JWTs carrying a space-delimited scope claim and a standard exp (token.service.ts generateOAuth2Tokens), both of which are read from the token. Claims are decoded without signature verification — we do not hold the signing secret — which is sound only because it is paired with the live /auth/profile call that proves the token genuine.

Scopes therefore reach tool handlers in AuthInfo.scopes, and requireBearerAuth can enforce them. Nothing is gated on scope at the transport level today: the backend's own requireScope middleware already enforces "at least one of" per route, and gating the whole MCP session on a single scope would be coarser than that. Per-tool enforcement is the natural next step.

Token verification is a live call to /auth/profile, cached 60s per token. A token revoked mid-window is still refused by TMS on the actual API call. Client registrations are held in memory, so a restart makes clients re-register; a multi-instance deployment needs shared storage for them.

Tool profile

TMS_TOOL_PROFILE=readonly exposes only the 16 tools that cannot modify TMS and withholds every mutating one — create_task, update_task, delete_task, comments, tags, links, log_time, and all of the definition-management tools. Intended for a first production-adjacent deployment, where a connector should be able to read the task system but not write to it.

Read-only surface: get_task, list_tasks, list_comments, list_links, list_tags, list_task_types, list_statuses, list_categories, list_employees, list_sprints, get_sprint, list_tag_definitions, get_task_history, get_task_activity, get_time_spent, whoami.

Three things worth knowing:

  • It is an allowlist. Under readonly, anything not named in READ_ONLY_TOOLS is withheld — so a tool added later is withheld until somebody classifies it, rather than exposed because nobody marked it mutating.
  • It is enforced at call time as well as in the listing, so a client working from a stale tool list cannot reach a withheld tool.
  • It is independent of the transport gate. connect, server_info and update_server are withheld over HTTP regardless of profile; the profile is about what the server may do, not how it is reached, so readonly restricts stdio too.

The profile is resolved once at startup: a bad value stops the process rather than surfacing on a user's first tool call, and the startup log states which profile is active.

Deploying

docker build -t tms-mcp .
docker run -p 8080:8080   -e TMS_PUBLIC_URL=https://mcp.example.com   -e TMS_HTTP_ENV=dev   -e TMS_OAUTH_CLIENT_ID=ems-mcp-remote   -e TMS_TOOL_PROFILE=readonly   -e TMS_TRUST_PROXY=1   tms-mcp

For AWS — CloudFormation, deploy workflows, and the constraints that matter — see aws/README.md. Two shapes are provided: Lambda with a Function URL and DynamoDB (primary, ~$0.60/month, no load balancer) and ECS behind an ALB (fallback). Neither has been deployed anywhere. Deploys are triggered by a push to the dev branch, matching the API; main never deploys.

The image serves the HTTP transport only (TMS_TRANSPORT=http is baked in); stdio is run from a checkout and is not containerised. It runs as a non-root user, and dumb-init is PID 1 so SIGTERM reaches node.

Two probes, deliberately different:

| | Checks TMS? | Use for | |---|---|---| | /healthz | no | liveness / container health | | /readyz | yes | load-balancer target health |

Liveness must not depend on TMS, or a backend outage would restart-loop healthy containers. Readiness must, because token verification does — an unreachable backend means nothing can be served.

/readyz tolerates a cold upstream: the dev backend is a Lambda measured at ~12s cold and ~1s warm, so the probe allows 15s but serves the last known result while refreshing in the background. Only the very first probe after start can block — give the orchestrator a start-period/initial-delay of ~20s.

Shutdown. On SIGTERM the listener stops accepting, then every live MCP session is closed so its SSE stream ends cleanly rather than leaving clients on a dead socket, with a 10s hard deadline. Verified in-container: docker stop exits 0, not 143.

TLS is terminated outside this process. It speaks plain HTTP and must sit behind a proxy or load balancer that terminates TLS; TMS_PUBLIC_URL must be the https origin, since it becomes the OAuth issuer. Set TMS_TRUST_PROXY to the hop count so rate limiting sees real client IPs.

Abuse limits. /mcp is rate limited to 240 requests/minute/IP, and rejected tokens are cached for 30s — without both, an unauthenticated caller could turn this server into an amplifier aimed at TMS, since every request costs one upstream token verification. A backend outage is deliberately not cached, so recovery is immediate.

Sessions are bound to the user who opened them. A session id presented by a different subject is answered exactly like an unknown one, so no authenticated user can attach to another's event stream or tear down their session.

Before it can serve real traffic, an ems-mcp-remote OAuth2 client must be seeded with this deployment's callback — see the section above.

Tests

npm test           # vitest run
npm run test:watch
npm run type-check # src AND tests

59 tests across four files, covering the properties that are expensive to get wrong:

| File | Covers | |---|---| | tests/oauthProvider.test.ts | the redirect-origin allowlist, LRU eviction, and the terminate-mode state machine — single-use codes, PKCE, client and redirect_uri binding, consent denial | | tests/verifyToken.test.ts | scopes and expiry read from the JWT, rejection caching, and an outage not being cached as a rejection | | tests/httpTransport.test.ts | session-to-user binding, probe output, the bearer gate, withheld tools, and the OAuth metadata surface | | tests/mode.test.ts | configuration validation and the safe defaults |

Tests import from src/ directly; vitest.config.ts strips the .js from NodeNext specifiers so no build step is needed first.

These were checked by mutation: removing the origin allowlist, the LRU touch, single-use code deletion, session binding, negative caching, or PKCE verification each makes the suite fail. A green run means something.

Token storage & how to wipe it

Tokens are stored per environment on disk, never in the repo, with restrictive permissions (file 0600, directory 0700):

  • Windows: %APPDATA%\tms-mcp\tokens.json
  • macOS / Linux: $XDG_CONFIG_HOME/tms-mcp/tokens.json (or ~/.config/tms-mcp/tokens.json)

To wipe credentials (force a fresh login), delete that file or just the environment's entry:

# macOS / Linux
rm ~/.config/tms-mcp/tokens.json
# Windows PowerShell
Remove-Item "$env:APPDATA\tms-mcp\tokens.json"

Troubleshooting

  • "Authentication required for <env>. Run connect." — No stored token, or the refresh token expired/was revoked. Run connect{ env: "<env>" } again to re-login.
  • OAuth error during login (e.g. invalid_grant, access_denied) — surfaced verbatim. Retry connect; if it persists, confirm the backend OAuth client (ems-mcp-cli) is seeded and active.
  • Environment down / wrong host — Network errors include the host that was unreachable. Confirm the environment is up and that the host in src/config/environments.ts is correct (the dev/staging hosts are placeholders pending real values — see the TODO comments there).
  • X-Device-Signature rejected (401 on every call) — The server sends X-Device-Signature: oauth2:<clientId> automatically. If the backend still rejects it, ensure TMS_OAUTH_CLIENT_ID matches the OAuth client id seeded on the backend.
  • Port already in use — The loopback listener binds an OS-assigned ephemeral port on 127.0.0.1, so collisions are rare. If a previous login hung, start a new connect; the old listener is torn down on completion/timeout.
  • Browser didn't open — The login URL is printed to the server's stderr; open it manually.
  • Nothing logged? — Set TMS_DEBUG=1 for verbose stderr logging. All diagnostics go to stderr (stdout is reserved for the MCP protocol).

Configuration notes

  • src/config/environments.ts maps prod/staging/dev/local to { baseUrl, webOrigin }. prod is https://api.ai2aim.ai / https://platform.ai2aim.ai; the dev and staging hosts are placeholders — replace them with the real deployed hostnames.
  • local defaults to backend http://localhost:3001 and frontend http://localhost:5173.