@ticketlayer/mcp
v2.0.0
Published
Ticketlayer's MCP server - bring your own agent. Exposes the permissioned Backstage API as MCP tools under the caller's own credential, over stdio or Streamable HTTP.
Readme
backstage-mcp
Bring your own agent to Ticketlayer. An MCP server that exposes the
permissioned backstage API as MCP tools. Every tool call forwards the caller's
own credential to an existing /v1/* route, so an external agent can never
exceed the human it acts for. No database. No model key (the agent brings
its own model).
- Two ways in: remote Streamable HTTP at
POST /mcpon port 3506 with an OAuth 2.1 bearer from the identity issuer, or stdio asnpx @ticketlayer/mcpwith an organisation API key (tlak_) in the client's env block. - Published to npm as
@ticketlayer/mcp. Staging ishttps://mcp.staging.t9r.dev/mcp; localhttps://mcp.local.t9r.dev/mcp. - Authorization server: the identity issuer,
auth.<domain>(https://auth.staging.t9r.devon staging), served by backstage's identity domain. Downstream resource: backstage-api (unchanged). - The tool registry is generated from
openapi.json, a snapshot of backstage-api's generated spec, filtered to curated allowlists (src/registry/allowlist.ts): 60 tools today - 52 route reads, 4 curated composites and 4 writes.
CLAUDE.md is the working agreement: run, test, refresh the spec, deploy,
every environment variable, endpoints and the auth rules.
How it fits together
agent --Streamable HTTP + OAuth 2.1 (DCR + PKCE + resource, granular scopes)--> backstage-mcp (3506)
local client --stdio, TICKETLAYER_API_KEY=tlak_...-----------> backstage-mcp (subprocess)
| forwards the caller's
| Bearer + X-Ticketlayer-Org
| (org_slug claim, or the
| API key's organisation)
v
backstage-api /v1/* (requirePermission, 404-not-403)- Single endpoint
/mcp- no org subdomains, no org in the URL, matching the platform's single-host Backstage and identity-issuer model. - Org comes from the credential. In OAuth mode it is the token's signed
org_slugclaim (chosen in the identity issuer's org picker at sign-in). In API-key mode it is the organisation thetlak_key hashes to: backstage-api requiresX-Ticketlayer-Orgto name that same organisation and answersORG_MISMATCHotherwise, so the slug configured beside the key asserts and the key authorises. Either way cross-tenant access is structurally impossible, and the org is never read from a request header or a tool argument. - This server's own audience required, and each tool's own scope. A token
is accepted only when its
audnameshttps://mcp.<domain>/mcp, which the identity issuer adds (besidebackstage-api) only when the client asked for it withresource(RFC 8707). An ordinary Backstage sign-in token (aud backstage-apialone) cannot be replayed here. Each tool then needs its own granular scopes (events.read,orders.read, ...; see Scopes), which a person consented to by name: a call without them is a 403insufficient_scopenaming them. There is no broadmcpscope any more (TKT-172). - No tool takes
organisationId(enforced bytest/registry.tripwire.test.ts). - Writes are two tools,
createEventandupdateEvent, marked destructive so the MCP host asks for confirmation, and they need theevents.writescope on the credential. - Composites are reads, always. A curated composite assembles several allowlisted reads into one answer; if one ever needed to write, it would be a write tool with host confirmation and a write scope.
- Some operations are refused on purpose (
NEVER_EXPOSEinsrc/registry/allowlist.ts):signUp,graduateOrganisation,createLedgerSettlement, the sandbox payment confirmations andcheckCanSell. Each entry carries its reason, and a tripwire test asserts none of them is reachable.
Endpoints
| Method | Path | Purpose |
|---|---|---|
| GET | /health | liveness + tool count |
| GET | /.well-known/oauth-protected-resource/mcp | RFC 9728 metadata |
| POST | /mcp | MCP requests (stateless Streamable HTTP) |
The composite tools
Most tools are one route each. Four are one question each, assembled from
reads the registry already exposes (src/registry/composites.ts):
| Tool | Answers | Steps |
|---|---|---|
| eventSalesSnapshot | how is this event selling | event, occurrences, inventory, sales summary |
| eventOnSaleCheck | can anyone actually buy it, and where | event, channels, each channel's offers in the staff view (first 20): state, reason, price with fees |
| orderLookup | what did ORD-ABC123 buy, and have the tickets been used | order by number, its passes, its customer |
| eventSettlementSnapshot | who owes whom for this event, and what is settled | the event statement (owed, settled, outstanding per holder), the settlements allocated to it, each holder's balances by event (first 20) |
Steps run sequentially under the same forwarded credential, so each one is permissioned exactly as if the model had called it itself - a composite is a shortcut through the API, never around it. A step whose data is genuinely optional (an order with no customer) or whose permission the credential may lack (the reporting step) leaves a note in the result instead of failing the call.
Connect an AI agent
Two credentials, and which one you want depends on how the client runs.
| | OAuth (remote) | API key (stdio) |
|---|---|---|
| Transport | Streamable HTTP, POST /mcp | stdio subprocess |
| Credential | identity-issuer access token for this resource, with the scopes its tools need | organisation API key, tlak_ |
| Acts as | the human who logged in, capped by the token's scopes | the key, capped by the key's scopes |
| Organisation | the token's signed org_slug | the key's own organisation |
| Needs the identity issuer | yes (sign-in, consent, refresh) | no |
| Good for | hosted agents, shared deployments, per-user consent | Claude Code, Claude Desktop, Cursor on a laptop |
With an organisation API key (stdio)
Mint a key in Backstage (Settings, API keys) with the scopes the agent should have, note the organisation slug, and point the client at the published package:
{
"mcpServers": {
"ticketlayer": {
"command": "npx",
"args": ["-y", "@ticketlayer/mcp"],
"env": {
"TICKETLAYER_API_KEY": "tlak_...",
"TICKETLAYER_ORG": "your-org-slug",
"BACKSTAGE_API_URL": "https://api.staging.t9r.dev"
}
}
}
}That is claude_desktop_config.json for Claude Desktop, .mcp.json (or
claude mcp add) for Claude Code, and .cursor/mcp.json for Cursor - the
shape is the same. Nothing here needs the identity issuer or a person at a
browser, which is what makes it right for scripts and unattended agents.
claude mcp add ticketlayer \
--env TICKETLAYER_API_KEY=tlak_... \
--env TICKETLAYER_ORG=your-org-slug \
-- npx -y @ticketlayer/mcpThe key never leaves the machine that holds it: it is read once from the
process environment, and every tool call forwards it to backstage-api as
Authorization: Bearer tlak_... with X-Ticketlayer-Org naming the key's
organisation. A missing key, a key without the tlak_ prefix, or a key with
no TICKETLAYER_ORG beside it exits 2 with a sentence saying what to fix.
What an API key can reach. backstage-api caps a key by its granular scopes
(accounts, events, venues, inventory, saleschannels, orders,
customers, webhooks, tags, codes, ledger.read, reports.read -
SCOPE_CATALOGUE in its scope-policy.ts), the same scopes an OAuth token
carries, so Scopes says what each tool needs either way. A tool
whose scope the key lacks answers a clean 403 naming it (an account-pinned key
reads its Account's side of the ledger). Recording or reversing a settlement
is never a tool.
API-key mode is stdio only. Over HTTP the process credential would serve
whoever reached the port, so the HTTP transport never consults it and warns at
boot if one is set. Every /mcp request is authenticated by its own bearer.
With OAuth (remote)
The server speaks Streamable HTTP with OAuth 2.1:
- An unauthenticated call to
/mcpanswers 401 with aWWW-Authenticate: Bearer resource_metadata=..., scope=...challenge; the scope is the default reads (below). - The client reads
/.well-known/oauth-protected-resource/mcp, which names the identity issuer,https://auth.<domain>, as the one authorization server, then reads the issuer's own metadata (/.well-known/oauth-authorization-server). - It registers itself at
/oauth/register(RFC 7591 dynamic registration: public PKCE clients, https or loopback redirects, no secret). - It sends the person to
/oauth/authorizewith PKCE, the scopes the challenge named andresource=https://mcp.<domain>/mcp(RFC 8707). The person signs in onauth.<domain>, picks an organisation and consents to each scope, worded on the consent page. - The token it gets back has
aud: ["backstage-api", "https://mcp.<domain>/mcp"]. This server requires its own resource inaud; the API requiresbackstage-api, so the one token serves both.
A client that leaves resource out gets a token for the API alone, and this
server refuses it (401, invalid_token, with a description naming the
resource to ask for). The MCP authorization spec requires clients to send
resource, so a conforming client does.
Scopes
The identity issuer grants granular scopes, and each tool needs its own
(src/registry/scopes.ts, pinned by test/registry.tripwire.test.ts):
| Scope | Tools |
|---|---|
| none | getCurrentUser, getMyOrganisations, getMyPermissions, getMyRoles |
| events.read | events, occurrences, price schemes, offers |
| venues.read | venues and layouts |
| inventory.read | inventory and reservations |
| orders.read | orders and their passes |
| customers.read | customers and the tags a customer holds |
| tags.read | tag definitions |
| saleschannels.read | sales channels and their listings |
| accounts.read | accounts |
| reports.read | reports, queries, saved reports and dashboards |
| codes.read | codes (opt-in) |
| ledger.read | balances, statements, settlements and payouts (opt-in) |
| events.write | createEvent, updateEvent, createEventPriceScheme, updateEventPriceScheme |
A composite needs every scope its steps need (eventSalesSnapshot:
events.read inventory.read reports.read).
scopes_supportedin the protected-resource metadata is every scope above.- The 401 challenge asks for the reads without the opt-in ones:
accounts.read customers.read events.read inventory.read orders.read reports.read saleschannels.read tags.read venues.read. That is also what the issuer registers a client for when it names no scope. Codes, the ledger andevents.writeare granted only to a client that asks for them by name. - A tool call without its scopes is answered 403 with
WWW-Authenticate: Bearer error="insufficient_scope", scope="...", where the scope is what the token holds plus what the tool needs, so the client can authorize again for them without losing anything (step-up). A client registered with the defaults registers again to ask for a scope beyond them. - Re-consenting. The
error_descriptionof that 403, a tool result whose backstage-api call was refused with 403, and the server's MCPinstructions(sent on initialize,src/instructions.ts) all say the same thing to the model: the permission was not granted, do not retry, tell the person which scope is missing and ask them to re-authorise the connector (in Claude, remove and re-add or reconnect it) and approve the consent screen, which lists the new permission. An agent cannot grant itself a scope.
The scopes cap the tools; the person's own permissions still cap the scopes, in backstage-api, on every call.
On staging
auth.staging.t9r.dev and mcp.staging.t9r.dev are both on the public
ingress class, so the whole flow works from anywhere, including clients that
run registration and the token exchange from their own servers (Claude's
connectors). The sign-in and consent pages on auth.staging.t9r.dev are the
shell's (backstage-app apps/shell). You need a staff account on staging to
sign in.
Claude Code
claude mcp add --transport http ticketlayer https://mcp.staging.t9r.dev/mcp
# or for every project:
claude mcp add --scope user --transport http ticketlayer https://mcp.staging.t9r.dev/mcpThen inside a session run /mcp, choose ticketlayer and authenticate; the
browser opens auth.staging.t9r.dev for sign-in, org choice and consent, and the token is
stored and refreshed by Claude Code. To share it with a repo, commit a
project-scoped .mcp.json:
{
"mcpServers": {
"ticketlayer": {
"type": "http",
"url": "https://mcp.staging.t9r.dev/mcp"
}
}
}Claude Desktop
Add a custom connector (Settings, Connectors) with the URL
https://mcp.staging.t9r.dev/mcp; Claude runs the registration and sign-in.
A stdio-only client can use the local mcp-remote bridge instead, which runs
the OAuth flow on your machine. In claude_desktop_config.json:
{
"mcpServers": {
"ticketlayer": {
"command": "npx",
"args": ["-y", "mcp-remote", "https://mcp.staging.t9r.dev/mcp"]
}
}
}The first call opens the browser for sign-in and consent.
MCP Inspector (to test)
npx @modelcontextprotocol/inspectorChoose transport Streamable HTTP, enter https://mcp.staging.t9r.dev/mcp
(or https://mcp.local.t9r.dev/mcp against a local stack) and connect; it
walks the OAuth flow and lets you call tools by hand.
For a local stack substitute https://mcp.local.t9r.dev/mcp in any block
above.
Local development
npm ci
cp .env.example .env
npm run dev # HTTP transport, tsx watch, port 3506
npm run dev:stdio # stdio transport; needs TICKETLAYER_API_KEY + TICKETLAYER_ORG
npm test # vitest: registry tripwire, composites, bearer and audience, API-key mode, config guard, platform-envDrive the stdio server by hand:
TICKETLAYER_API_KEY=tlak_... TICKETLAYER_ORG=your-org \
BACKSTAGE_API_URL=http://localhost:3501 \
npx @modelcontextprotocol/inspector npm run dev:stdioQuick check without OAuth, forwarding a bearer unvalidated (local only; the server refuses to boot with this set in production):
MCP_DEV_SKIP_TOKEN_VALIDATION=true BACKSTAGE_API_URL=http://localhost:3501 npm run dev
# point MCP Inspector at http://localhost:3506/mcp with a staff bearer in the
# Authorization header and x-ticketlayer-org set, or validate for real:
# IDENTITY_ISSUER=http://localhost:3501 \
# IDENTITY_JWKS_URL=http://localhost:3501/.well-known/jwks.json \
# BACKSTAGE_API_URL=http://localhost:3501 npm run dev
# (IDENTITY_ISSUER must equal the API's own IDENTITY_ISSUER, which defaults to
# http://localhost:<PORT> on a bare-metal API; the local stack uses
# https://auth.local.t9r.dev:3500.)Refreshing openapi.json
openapi.json at the repo root is a snapshot of backstage-api's generated
OpenAPI document. It is what the Docker image ships (OPENAPI_SPEC_PATH
points at it) and the default for npm test and npm run dev
(OPENAPI_SPEC_PATH overrides it). Regenerate it from a ticketlayer/backstage
checkout whenever the API's routes change:
(cd ../backstage && npm ci && npm run spec:generate) # writes backstage/openapi.json
cp ../backstage/openapi.json ./openapi.json
npm test # tripwire must still passCommit the refreshed openapi.json with the change that needed it.
Deploying
Push to main. The workflow builds ghcr.io/ticketlayer/backstage-mcp:main-<sha>
and commits the tag into ticketlayer/deployment k8s/staging-lon1/values.yaml;
Argo CD rolls it out within a few minutes.
Releasing to npm
The same push runs .github/workflows/release.yml. Changesets keeps a
"Release @ticketlayer/mcp" pull request up to date while unreleased changesets
exist; merging it versions the package, publishes it with npm trusted
publishing (a GitHub OIDC token, no NPM_TOKEN) and tags the release.
Provenance attestations are off because npm refuses them for a private source
repository - see the header of the workflow. Add a changeset with
npx changeset for any change an MCP client can see: the tool list, a tool's
arguments, the credential modes, the bin name.
If a merge to main did not run Release (a GitHub startup failure did exactly
that to the first release), run it again from the Actions tab: Release has a
workflow_dispatch trigger, and on main with no changesets pending it
publishes the version package.json names if npm does not have it yet.
The first publish of the package was by hand, because npm registers a
trusted publisher only on a package that already exists: npm ci,
npm run build, npm publish from a clean main, then the trusted publisher
added on npmjs.com (GitHub Actions, ticketlayer / backstage-mcp /
release.yml). From then on nothing is published from a laptop.
Auth model
OAuth. The identity issuer (auth.<domain>, backstage's identity
domain) is the single OAuth authorization server, with Dynamic Client
Registration (RFC 7591): a third-party agent self-registers, runs PKCE asking
for granular scopes with resource set to this server (RFC 8707), and the
person picks an organisation and consents at the issuer. The resulting RS256
token carries a signed org_slug, its scope and
aud: [backstage-api, <this server>/mcp]. This server validates it (the
issuer's JWKS, issuer, expiry, its own resource in aud, an org_slug),
checks each tool call against the token's scopes (403 insufficient_scope
otherwise), then forwards it unchanged plus
X-Ticketlayer-Org = org_slug to backstage-api, which checks backstage-api
in aud and re-asserts the organisation.
API key. An organisation API key is not a JWT and has no claims to read:
backstage-api hashes it to a row that names its organisation, and then requires
X-Ticketlayer-Org to name that same organisation
(authenticateOrgApiKey -> requireOrgHeaderMatch). No route will tell a key
which organisation it belongs to without that header, so TICKETLAYER_ORG is
configured beside the key. Because the API re-asserts it, a wrong slug is a 401
ORG_MISMATCH and never a reach into another tenant: the slug asserts, the key
authorises. This is deliberately not a second MCP_DEV_SKIP_TOKEN_VALIDATION -
that flag lets the caller pick the tenant per request; here the tenant is
fixed by the operator's own environment before any client connects, and the
HTTP transport refuses to use it at all.
Related
ticketlayer/backstagesrc/domains/identity- the authorization server (discovery, JWKS, registration,resourceindicators).docs(public site)docs/guides/agents/mcp.md- the customer-facing guide, including Cursor and VS Code blocks and the analytics tools.ticketlayer/deployment- runbook and env contract.
