@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 nativefetch,node:crypto, andnode:http. - A browser on the machine running the MCP server (login opens a consent page).
- Network access to the target TMS environment.
- The
claudeCLI onPATH(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 onlyOptions:
| 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
tmsregistration (user/local scope), thenclaude mcp add tms --scope user …with the env vars below, and printsclaude mcp list. - Cursor — merges a
tmsentry into the global~/.cursor/mcp.json(Windows:%USERPROFILE%\.cursor\mcp.json). Other servers in that file are left untouched; only thetmsentry is overwritten. Cursor has nomcp addCLI, 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.jsOptional 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.jsRegister 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).
- In a Claude session, call
connect(optionallyconnect{ env: "dev" }). - The server generates a PKCE pair +
state, starts a temporary loopback listener onhttp://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. - The consent page requires you to be logged in to the SPA (it round-trips through
/loginpreserving the OAuth params). Log in if prompted. - Pick your organization and review the requested scopes
(
tasks:read,tasks:write,comment:write,tag:write,link:write,offline_access), then Approve. - 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
prodorstagingis deliberate
prodandstagingare never the default and the server will not silently fall back to them. Only passenv: "prod"orenv: "staging"(inconnectand subsequent tools) when you genuinely intend to act against that environment —prodis 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 withuse_org. An explicitorgdoes not change it. - An
orgthat 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 withoutorgfail 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_orgsshows 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.jsonkeepstokens[env]in the old single-token shape, always a copy of the active org, with the org list under a separateorgskey, 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
connectagain.
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/tasksroutes. 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.jsRegister 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
envargument 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), plusserver_infoandupdate_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.jsonis 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:
ems-backend— seeder20270910000000-seed-mcp-remote-oauth2-client.jsregisters a separateems-mcp-remotepublic client, leaving the CLI's loopback registration untouched. SetMCP_REMOTE_REDIRECT_URISto the deployment's callback (https://<host>/oauth/callback) and runnpm run db:seed— ordb:seed:forceto re-converge after a hostname change. Unset means "no hosted MCP server here" and the seeder skips.employee-management-system— the consent page previously rejected non-loopback redirect URIs in the SPA, before the backend was asked.oauth-consent.tsxnow performs only a syntactic check and leaves permission toGET /auth/oauth2/consent, which validates against the registeredredirect_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 inREAD_ONLY_TOOLSis 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_infoandupdate_serverare withheld over HTTP regardless of profile; the profile is about what the server may do, not how it is reached, soreadonlyrestricts 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-mcpFor 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 tests59 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. Retryconnect; 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.tsis correct (thedev/staginghosts are placeholders pending real values — see theTODOcomments there). X-Device-Signaturerejected (401 on every call) — The server sendsX-Device-Signature: oauth2:<clientId>automatically. If the backend still rejects it, ensureTMS_OAUTH_CLIENT_IDmatches 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 newconnect; 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=1for verbose stderr logging. All diagnostics go to stderr (stdout is reserved for the MCP protocol).
Configuration notes
src/config/environments.tsmapsprod/staging/dev/localto{ baseUrl, webOrigin }.prodishttps://api.ai2aim.ai/https://platform.ai2aim.ai; thedevandstaginghosts are placeholders — replace them with the real deployed hostnames.localdefaults to backendhttp://localhost:3001and frontendhttp://localhost:5173.
