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

@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 /mcp on port 3506 with an OAuth 2.1 bearer from the identity issuer, or stdio as npx @ticketlayer/mcp with an organisation API key (tlak_) in the client's env block.
  • Published to npm as @ticketlayer/mcp. Staging is https://mcp.staging.t9r.dev/mcp; local https://mcp.local.t9r.dev/mcp.
  • Authorization server: the identity issuer, auth.<domain> (https://auth.staging.t9r.dev on 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_slug claim (chosen in the identity issuer's org picker at sign-in). In API-key mode it is the organisation the tlak_ key hashes to: backstage-api requires X-Ticketlayer-Org to name that same organisation and answers ORG_MISMATCH otherwise, 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 aud names https://mcp.<domain>/mcp, which the identity issuer adds (beside backstage-api) only when the client asked for it with resource (RFC 8707). An ordinary Backstage sign-in token (aud backstage-api alone) 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 403 insufficient_scope naming them. There is no broad mcp scope any more (TKT-172).
  • No tool takes organisationId (enforced by test/registry.tripwire.test.ts).
  • Writes are two tools, createEvent and updateEvent, marked destructive so the MCP host asks for confirmation, and they need the events.write scope 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_EXPOSE in src/registry/allowlist.ts): signUp, graduateOrganisation, createLedgerSettlement, the sandbox payment confirmations and checkCanSell. 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/mcp

The 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:

  1. An unauthenticated call to /mcp answers 401 with a WWW-Authenticate: Bearer resource_metadata=..., scope=... challenge; the scope is the default reads (below).
  2. 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).
  3. It registers itself at /oauth/register (RFC 7591 dynamic registration: public PKCE clients, https or loopback redirects, no secret).
  4. It sends the person to /oauth/authorize with PKCE, the scopes the challenge named and resource=https://mcp.<domain>/mcp (RFC 8707). The person signs in on auth.<domain>, picks an organisation and consents to each scope, worded on the consent page.
  5. The token it gets back has aud: ["backstage-api", "https://mcp.<domain>/mcp"]. This server requires its own resource in aud; the API requires backstage-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_supported in 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 and events.write are 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_description of that 403, a tool result whose backstage-api call was refused with 403, and the server's MCP instructions (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/mcp

Then 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/inspector

Choose 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-env

Drive 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:stdio

Quick 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 pass

Commit 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/backstage src/domains/identity - the authorization server (discovery, JWKS, registration, resource indicators).
  • 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.