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

@truxie/mcp

v0.3.1

Published

Model Context Protocol adapter for truxie — exposes opt-in controller routes to an MCP client as four generic tools, dispatched in-process through the application's own guard chain

Readme

@truxie/mcp

Expose a truxie application's HTTP API to an MCP client — Claude Code, Claude Desktop, or any other — as four generic tools instead of one tool per endpoint, with exposure opt-in per route and authorization left to the guards you already wrote.

npm install @truxie/mcp

Why four tools

An API with 200 routes turned into 200 MCP tools puts the whole API surface into every request's context window, and leaves less room for the work. This package inverts that: the catalog is data the model fetches when it needs it, not a permanent tax on every request.

| Tool | Purpose | |---|---| | list_endpoints | What can I call? Already filtered to the caller's permissions. | | describe_endpoint | The full input contract for one endpoint, plus a worked example. | | call_endpoint | Run it. | | whoami | Who am I and what do I hold? |

The cost is one extra round trip before the first call. For the handful of endpoints an agent uses constantly, promote lifts one to a first-class tool with its own schema — pay the tokens only where they earn their keep.

Quick start

import {ApiDoc, Controller, Delete, Get, RouteGuards} from 'truxie';
import {RequireScope, requiredScopesOf} from '@truxie/rbac';
import {createMcpHandler, createMcpNodeHandler, McpExpose, oauthResourceServer, staticToken} from '@truxie/mcp';
import {zodToJsonSchema} from '@truxie/zod';

@Controller('users')
class UsersController {
  @Get('/')
  @ApiDoc({
    summary: 'List users. Admin search by email or role; for the caller use GET /api/me.',
    tags: ['users'],
    returns: {schema: userPageSchema, description: '`nextCursor` is null on the last page.'},
  })
  @McpExpose()
  @RouteGuards(RequireScope('users:read'))
  list(@ListUsersQuery() query) {}

  @Delete('/:id')
  @ApiDoc({
    summary: 'Delete a user permanently.',
    errors: [{status: 409, when: 'the user owns active orders', then: 'cancel them first'}],
    related: ['GET /api/users'],
  })
  @McpExpose({
    write: true,
    dangerous: true,
    idempotent: true,
    confirmPrompt: 'This permanently deletes the user and their order history. Proceed?',
    sideEffects: ['revokes every active session for that user'],
  })
  @RouteGuards(RequireScope('users:delete'))
  remove(@Param('id') id: string) {}
}

const mcp = createMcpHandler({
  app,
  serverInfo: {name: 'acme-admin', version: '1.0.0'},
  authenticate: [
    oauthResourceServer({issuer: OIDC_ISSUER, audience: 'acme-mcp', toPrincipal: resolveLocalUser}),
    staticToken({token: process.env.MCP_AUTOMATION_TOKEN!, id: 'automation', scopes: ['reporting:read']}),
  ],
  describeGuard: requiredScopesOf,
  toJsonSchema: zodToJsonSchema,
  activity: batchActivitySink((rows) => db.insert(mcpActivity).values(rows)),
  protectedResource: {
    resource: 'https://api.acme.com/mcp',
    authorizationServers: [OIDC_ISSUER],
  },
});

server.use('/mcp', createMcpNodeHandler(mcp));

Mount it before any body parser — the JSON-RPC body is read from the raw stream.

Documentation: write it once, render it twice

@ApiDoc() lives in truxie core and is read by both this package and @truxie/openapi. Write the contract once; render it as an MCP catalog for agents and as an OpenAPI 3.1 document for people and tooling.

import {generateOpenApiDocument} from '@truxie/openapi';

const spec = generateOpenApiDocument(app, {
  info: {title: 'Acme API', version: '1.0.0'},
  toJsonSchema: zodToJsonSchema,
  describeGuard: requiredScopesOf,
});

@McpExpose() accepts every @ApiDoc() field and forwards it, so a route can be documented on the MCP decorator alone and still render correctly in the OpenAPI document — there is no second place to write the same prose. An explicit @ApiDoc() wins over anything @McpExpose() declares, in either decorator order, so adding exposure to an already-documented route changes nothing about its documentation.

The reverse does not hold, deliberately: @ApiDoc() grants nothing. A route with @ApiDoc alone appears in the OpenAPI spec and is invisible to MCP. Documenting an endpoint must never be what publishes it to an agent.

Per-parameter types belong in the schema the handler validates with — @truxie/zod records it, and both renderers read it from there. A parallel @ApiProperty-style layer would be a second source of truth, and the week after it is written the validator and the docs disagree. The one exception is a handler that validates by hand: request: {query, body, params} (on either decorator) documents its input, loses to any schema a decorator records, and routeInputDrift() from truxie core catches it drifting from the keys the handler actually reads.

What a schema cannot say

| Field | Why an agent needs it | |---|---| | returns | Otherwise the only way to learn the response shape is to call the endpoint — not a question to ask experimentally on a write. | | errors | Knowing a call can 409 matters much less than knowing what to do instead of retrying. | | related | Guessing where an id came from is the most common way a call goes wrong. | | examples | One realistic example beats a schema for getting the first call right. | | deprecated | Flagged in the listing and at the top of describe_endpoint. |

What OpenAPI has no field for

These live on @McpExpose() only. They are not documentation — they are what a caller that cannot ask a follow-up question has to be told up front.

| Field | | |---|---| | write | Required on any non-GET route. A mutation that forgets it fails at startup. | | dangerous | call_endpoint refuses until the caller also passes confirm: true. | | confirmPrompt | The question to put to the user, naming the consequence. Without it the model invents one, and what it invents is "shall I proceed?", which nobody can answer well. Declaring it without dangerous fails at startup — a confirmation nothing will ever ask for reads as a safeguard while being none. | | idempotent | Whether repeating the identical call is safe. The most expensive thing an agent gets wrong: a call times out, it retries, the card is charged twice. Undeclared on a write stays unknown rather than being guessed into true. | | sideEffects | What the call does besides returning a response. A 200 does not tell a model that four hundred people were just emailed — and this is what it repeats back when it asks for confirmation. | | cost | 'expensive' is flagged in the listing itself, so the cheap endpoint gets picked when either would do rather than the difference being discovered by timing out. | | rateLimit | The limit in the caller's own terms — '60 requests per minute per token'. | | pagination | {style, param, limitParam, nextField, itemsField}. A model inferring the convention from a response body guesses page=2 against a cursor API and silently re-reads page one forever. | | promote | Lift this endpoint to a first-class MCP tool with its own schema. |

They reach the OpenAPI document too, as x-mcp-* Specification Extensions on the operation, rather than being dropped at the renderer boundary:

/users/{id}:
  delete:
    operationId: remove
    summary: Delete a user permanently.
    x-mcp-exposed: true
    x-mcp-write: true
    x-mcp-dangerous: true
    x-mcp-idempotent: true
    x-mcp-confirm-prompt: This permanently deletes the user and their order history. Proceed?
    x-mcp-side-effects:
      - revokes every active session for that user

@ApiDoc({extensions: {'x-owner-team': 'billing'}}) is the same door for your own x- keys. A key that does not start with x- is dropped rather than emitted: one stray key fails validation of the whole document.

OpenAPI has no field for related or examples either, so the generator folds those into the operation description; the MCP catalog gives each its own section.

The security model

Three layers, and only one of them is a boundary.

1. Exposure is opt-in. A route with no @McpExpose() is invisible and uncallable — documenting it with @ApiDoc() does not change that. A controller added next month is not reachable by an agent until someone deliberately says it is. This is the reason it is an allowlist and not a filter you configure: a denylist has the opposite default, and the failure mode is silent.

2. Visibility is filtered by scope. list_endpoints hides what the caller's scopes cannot satisfy, so the model does not attempt calls it will lose. This is not the boundary — it is the same distinction as a menu that hides an admin link. Getting it wrong shows a model an endpoint it will then be refused.

3. Authorization is the guard chain, unchanged. call_endpoint dispatches in-process through matchRoute → guards → pipes → interceptors → handler → filters, the same sequence the HTTP adapters drive, against a synthesized Web Request carrying the caller's credentials. A guard cannot tell the call arrived over MCP, so it cannot be bypassed by one.

On top of that:

  • Writes are declared. A non-GET route exposed without write: true fails at startup rather than quietly handing an agent a mutation.
  • dangerous: true makes call_endpoint refuse until the caller passes confirm: true, and the refusal hands the model confirmPrompt plus the endpoint's sideEffects — the words to put to the user, not an invitation to improvise them.
  • readOnly: true refuses every write at the deployment level, whatever the decorators say. McpPrincipal.readOnly does the same for one caller — see Read-only principals.
  • Path parameters stay in their segment. A value that is empty, . / .. (also as %2e spellings, at any encoding layer), or decodes to /, \ or a control character is refused (400, activity refusal invalid_request) before dispatch, local or remote; a remote call is also refused if URL parsing changed the path it was built for.
  • Responses are truncated at maxResponseBytes (default 100 KB) with a notice, so one call cannot fill the context window.
  • activity logs every request — who, which tool and endpoint, outcome, refusal reason, redacted arguments — and cannot fail the call it is reporting on. See Activity log.
  • onCall receives every dispatched call — principal, endpoint, status, duration. Kept for compatibility; activity is the superset.

Activity log

activity takes one sink or several. Each gets one McpActivity per request:

| Field | | |---|---| | id, at | uuid (use it as the row key — a retried write stays idempotent) and time | | server | serverInfo.name, to tell several MCP servers apart in one table | | principal | {id, kind, name} — who called. Absent only for kind: 'auth' | | kind | initialize · tools_list · call (call_endpoint / promoted tool) · list · describe · whoami · tool (unknown name) · auth (no authenticator admitted it) | | outcome | ok · error (ran and failed, incl. a 4xx from the guards) · refused (stopped before anything ran) · unauthenticated | | refusal | not_found · read_only · needs_confirm · unknown_tool · invalid_request | | tool, endpointId, method, path, status | what was called and how it answered; path has params substituted | | source | 'local' or the remote's name, on a call / describe of a known endpoint | | args | tool arguments after redact (off with recordArgs: false) | | error | client-visible text, cut at 1000 chars | | client, userAgent, rpcId | clientInfo (on initialize only — the server is stateless), the User-Agent, the JSON-RPC id | | durationMs | |

ping and notifications are not recorded. Token claims are never copied in — derive what you need from principal inside your sink.

import {batchActivitySink, loggerActivitySink, DEFAULT_REDACT_KEYS} from '@truxie/mcp';

const toDb = batchActivitySink((rows) => repo.insertMany(rows), {maxBatch: 50, flushMs: 1000});
createMcpHandler({
  ...,
  activity: [toDb, loggerActivitySink(logger)],
  redact: {keys: [...DEFAULT_REDACT_KEYS, 'email']},
});
process.on('SIGTERM', () => void toDb.flush());
  • redact masks keys containing password, secret, token, authorization, cookie, apikey, card data … at any depth (case-insensitive; regexes allowed), cuts strings at 500 chars, arrays at 50 items, depth at 6.
  • batchActivitySink buffers so a request never waits on the database; a failed write is kept for the next flush (onError is told), at most maxBuffer (5000) records.
  • loggerActivitySink writes one line per record (info for ok, warn otherwise) to anything with info/warn — @truxie/logger's Logger fits. Arguments only with includeArgs.
  • memoryActivitySink keeps records in memory, for tests.
  • recordMcpActivity(sinks, record) + redactValue let an MCP server written by hand log the same shape.

Read-only principals

readOnly: true on a principal (an authenticator returns it; staticToken entries take it too) is the deployment-wide readOnly for one caller: write endpoints and promoted write tools are left out of what it is shown, calling one by id is refused with the same read_only refusal, and whoami says the credential is read-only. Enforced here, before dispatch — it holds even where the route's guards would allow the write.

oauthResourceServer({issuer, audience, toPrincipal: (claims) => ({readOnly: !hasRole(claims, 'mcp-write')})})

Coverage: @McpHidden

Exposure is opt-in, so a route without @McpExpose() is unreachable — and also indistinguishable from a route nobody thought about. @McpHidden(reason) records the decision not to expose, so a test can insist that every route has one:

@Controller('auth')
@McpHidden('session management is for people, not agents')   // every method…
class AuthController {
  @Get('/me') @McpExpose({summary: 'The current account.'}) me() {}   // …except one exposed explicitly
}

@Post('/refunds')
@McpHidden('moves money — staff issue refunds in the admin UI')
refund() {}
const {unclassified} = mcpCoverage(app, {include: (route) => route.rawPath.startsWith('/api/projects')});
expect(unclassified.map((r) => `${r.method} ${r.rawPath}`)).toEqual([]);
  • mcpCoverage(app, {include?}) → {exposed, hidden: {route, reason}[], unclassified}.
  • The reason is required and non-empty. A class-level @McpHidden covers every method except those carrying @McpExpose() — the method wins. Both decorators on one method fails at catalog build.
  • isMcpExposed(controllerClass, methodName) and getMcpHidden(controllerClass, methodName) read the same global-registry metadata, for a guard or audit in another package. @McpHidden grants and removes nothing at runtime.

Federation: one server, several services

An application split into services — each its own truxie app — can still offer ONE MCP endpoint. Each service exports its catalog; the server in front federates them. Remote endpoints are listed, described, promoted and called exactly like local ones; a call is forwarded to the service that owns it, whose guards decide.

On each remote service, serve exportCatalog from any route, guarded like any other service-to-service call:

@Controller('internal/mcp')
@McpHidden('service-to-service plumbing')
class McpCatalogController {
  @Get('/catalog')
  @RouteGuards(InternalKeyGuard)
  catalog() {
    return exportCatalog(app, {describeGuard: requiredScopesOf, toJsonSchema: zodToJsonSchema});
  }
}

On the server in front:

createMcpHandler({
  app,
  ...,
  remotes: [
    httpRemote({
      name: 'payment',
      baseUrl: process.env.PAYMENT_URL!,
      catalogPath: '/internal/mcp/catalog',
      headers: () => ({'x-internal-key': process.env.INTERNAL_API_KEY!}),   // catalog request only
    }),
  ],
  onRemoteIssue: (issue) => logger.warn('mcp remote', issue),
});
  • The descriptor is plain JSON: {format: 'truxie-mcp-catalog', version: 1, endpoints}, each endpoint carrying id, method, path, params, summary/description/tags, input schemas, returns/errors/examples/related, scopeGroups, and every @McpExpose flag (promote without any prefix — the federating server applies its own toolPrefix). Any other format or version is refused whole: a half-understood dangerous flag is the mistake that must not happen quietly.
  • Calls go to baseUrl + path + query with a JSON body on anything but GET and only the principal's headers — the caller's own credentials, never the catalog headers. Responses map to the same result a local call gives (status, JSON or text body, redirects reported not followed), and are truncated identically. timeoutMs (30 s) and the caller's abort apply.
  • Collisions never shadow. Local endpoints come first, then remotes in order; a remote id (or promoted tool name) already taken is dropped and reported to onRemoteIssue and remoteStatus().
  • Loading is lazy and never fatal. A remote's catalog loads on the first request that needs it and is cached for catalogTtlMs (60 s); once stale it is refreshed in the background while requests carry on with the cached one. A remote that fails to load contributes nothing, whoami names it as unavailable (details stay in your log), and it is retried within 15 s.
  • McpRemote is two functions — loadCatalog() and dispatch(input) — for any transport httpRemote does not cover.
  • handler.catalog stays the local catalog; await handler.currentCatalog() includes remotes, handler.remoteStatus() reports each one. McpEndpoint.source says where an endpoint runs: {kind: 'local', route} or {kind: 'remote', remote}.

Wiring your RBAC

If you use @truxie/rbac, there is nothing to wire but the one hook:

import {requiredScopesOf} from '@truxie/rbac';

createMcpHandler({app, describeGuard: requiredScopesOf, ...});

scopeSatisfies already defaults to @truxie/rbac's matcher, so the catalog filters with exactly the rules your guards decide with.

For a hand-rolled authorization layer, two hooks connect it:

describeGuard: (guard) => string[]               // the scopes a guard requires
scopeSatisfies: (granted, required) => boolean   // your matching semantics

describeGuard reads what the route already declares. If your guard factory stamps the scope on the class — the common pattern, and the one @truxie/rbac follows — it is a one-liner:

const requiredScopesOf = (g) => (typeof g?.requiredScope === 'string' ? [g.requiredScope] : []);

Return several scopes for an OR-guard. Guards are ANDed (truxie runs all of them), scopes within one guard are ORed.

Authentication

Authenticators are tried in order. Returning null declines (the next one gets a turn); throwing McpAuthError rejects outright — a malformed token must never fall through to a weaker scheme.

OAuth (per-person identity)

oauthResourceServer makes the server an OAuth 2.0 Resource Server: the client runs the login against your authorization server and arrives holding a token; this server only verifies it. That buys an identity per person rather than per deployment, so the catalog can be filtered to what that person may do and the audit log names them.

Two things to get right:

  • Give the MCP server its own audience. Do not reuse the audience of the API behind it. A token minted for that API would otherwise be accepted here — the token passthrough the MCP authorization spec forbids, and a path for any client of that API to drive this server.
  • toPrincipal is the exchange point. A provider token is usually not what your guards expect. Resolve the subject to a local account there, read its real scopes, and return the headers your guards do understand. Omit it and the caller's original Authorization is forwarded unchanged — correct only when the API accepts the same tokens.

protectedResource publishes RFC 9728 metadata at /.well-known/oauth-protected-resource and names it in the WWW-Authenticate header of every 401. That header is the whole bootstrap: a client with no credentials makes one request, reads it, and knows where to log in.

Token verification comes from @truxie/jwt — the same implementation your HTTP routes authenticate with, so there is one place to get it right. WebCrypto, no runtime dependencies: RS/PS/ES 256/384/512, JWKS discovery from the issuer, key rotation, rate-limited refetch. none is not in the table.

A bad token is a 401. An unreachable authorization server is a 503 and says so — reporting our own outage as "your token is invalid" sends the caller off to log in again, which cannot help and hammers a provider that is already struggling.

Static token (headless)

staticToken is for automation, a stdio transport running locally, a smoke test. It is one identity shared by everyone holding it: auditable, but not attributable, and revoking it revokes everybody. Prefer OAuth for humans.

Options

| Option | Default | | |---|---|---| | app | — | The initialized truxie application. | | authenticate | — | Authenticator chain. Empty is refused. | | describeGuard | — | Guard → required scopes. | | scopeSatisfies | wildcard matcher | Granted set satisfies a required scope. | | toJsonSchema | — | Schema → JSON Schema. Pass zodToJsonSchema from @truxie/zod. | | visibility | 'filter' | 'all' lists the whole catalog regardless of scope. | | readOnly | false | Refuse every write endpoint. | | maxResponseBytes | 100_000 | Truncation ceiling. | | toolPrefix | '' | Namespace tool names across several servers. | | activity | — | Activity log sink(s). | | redact | DEFAULT_REDACT_KEYS | How arguments are masked for activity. | | recordArgs | true | Put redacted arguments on activity records. | | onCall | — | Hook for dispatched calls (subset of activity). | | remotes | — | Other services' catalogs to federate. See Federation. | | onRemoteIssue | — | Told about remote load failures and dropped endpoints. | | protectedResource | — | RFC 9728 metadata. | | serverInfo | truxie-mcp | Reported on initialize. | | baseUrl | http://mcp.local | Origin for the synthesized request.url. |

Transport

Stateless HTTP: every request carries its own credentials and is answered on its own, so the server scales horizontally, survives a restart mid-conversation, and has no session store to secure. GET returns 405 — there is no SSE stream to offer — and JSON-RPC batching is refused.