agentleFS
ficaviolaodorata520ficaviolaodorata520/met-museum-mcp-server/skills/api-auth/SKILL.md

api-auth

Skill0 starsChanged 6 months agoApache-2.0View on GitHub

Authentication, authorization, and multi-tenancy patterns for `@cyanheads/mcp-ts-core`. Use when implementing auth scopes on tools/resources, configuring auth modes (none/jwt/oauth), working with JWT/OAuth env vars, or understanding how tenantId flows through ctx.state.

  • Reads credentials

The file

We split this skill into the rules it gives an agent

5 rules. About 0 are common, 2 are distinctive, and 3 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈2

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 3

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    When MCP_AUTH_MODE=none, auth checks are skipped and defaults are allowed.

    line 35
  2. Distinctive:

    Stdio mode: No HTTP auth layer. Authorization is handled entirely by the host process.

    line 149
  3. Distinctive:

    Must start and end with an alphanumeric character

    line 169
  4. Very specific:

    No path traversal sequences (../)

    line 170
  5. Very specific:

    No consecutive dots (..)

    line 171

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Overview

The framework handles auth at the handler factory level — tools and resources declare required scopes declaratively, and the framework enforces them before calling the handler. No try/catch or manual scope checking required for the common case.


Inline auth (primary pattern)

Declare required scopes directly on the tool or resource definition via the auth property. The handler factory checks ctx.auth.scopes against these before calling handler.

import { tool } from '@cyanheads/mcp-ts-core';

const myTool = tool('my_tool', {
  input: z.object({ query: z.string().describe('Search query') }),
  output: z.object({ result: z.string().describe('Search result') }),
  auth: ['tool:my_tool:read'],
  async handler(input, ctx) {
    // Only reached if caller has 'tool:my_tool:read' scope
  },
});

When MCP_AUTH_MODE=none, auth checks are skipped and defaults are allowed.


Dynamic auth

For runtime-computed scopes (e.g., scopes that depend on input values like a team or resource ID), use checkScopes from @cyanheads/mcp-ts-core/auth inside the handler:

import { checkScopes } from '@cyanheads/mcp-ts-core/auth';

handler: async (input, ctx) => {
  checkScopes(ctx, [`team:${input.teamId}:write`]);
  // Continues only if scope is satisfied
},

Signature: checkScopes(ctx: Context, requiredScopes: string[]): void

Throws:

  • McpError(Forbidden) — auth is active and one or more required scopes are missing
  • McpError(Unauthorized) — auth is enabled but no auth context exists on the request
  • No-ops when MCP_AUTH_MODE=none

Auth modes

Set via MCP_AUTH_MODE environment variable.

ModeValueBehavior
DisablednoneNo auth enforcement. All requests allowed.
JWTjwtLocal secret verification via MCP_AUTH_SECRET_KEY. Requires explicit DEV_MCP_AUTH_BYPASS=true to bypass in development.
OAuthoauthJWKS verification against an external issuer.

JWT config

VariableRequiredPurpose
MCP_AUTH_SECRET_KEYYes (unless bypass)Signing secret for HS256 JWT verification. Must be ≥ 32 characters.
DEV_MCP_AUTH_BYPASSNoSet to true to skip JWT verification in development. Blocked in NODE_ENV=production.
DEV_MCP_CLIENT_IDNoClient ID injected when bypass is active (default: 'dev-client-id').
DEV_MCP_SCOPESNoComma-separated scopes injected when bypass is active (default: ['dev-scope']).

Important: With MCP_AUTH_MODE=jwt, a missing MCP_AUTH_SECRET_KEY is a fatal startup error unless DEV_MCP_AUTH_BYPASS=true is explicitly set. Setting DEV_MCP_AUTH_BYPASS in production (NODE_ENV=production) is rejected at config parse time.

OAuth config

VariableRequiredPurpose
OAUTH_ISSUER_URLYesToken issuer URL (used for JWKS discovery)
OAUTH_AUDIENCEYesExpected aud claim value
OAUTH_JWKS_URINoOverride JWKS endpoint (defaults to {issuer}/.well-known/jwks.json)
MCP_SERVER_RESOURCE_IDENTIFIERNoRFC 8707 resource indicator URI. When set, the OAuth strategy validates that the token's resource or aud claim matches this value — throws Forbidden on mismatch.

JWT claims mapping

ClaimJWT FieldPurpose
clientIdcid / client_idIdentifies the calling client
scopesunion of scp, scope, mcp_tool_scopesGranted scope list (see below)
subsubSubject (user or service identity)
tenantIdtidTenant identifier — drives ctx.state scoping

scopes is the union of three claims, in this order:

ClaimFormSource
scparray of stringsOkta-style
scopespace-delimited stringOAuth 2.1 / OIDC standard
mcp_tool_scopesarray of strings or space-delimited stringCustom claim for OIDC providers that cannot inject scopes into scope during the authorization_code flow (Authentik, Keycloak < 26.5, Zitadel)

Auth0/Okta-style providers that already populate scp or scope need no migration. Other deployments add a property mapping returning {"mcp_tool_scopes": "tool:foo:read tool:bar:write"} — the framework unions it into ctx.auth.scopes alongside the standard claims. Hardcoded claim name; deployments whose IdP cannot emit mcp_tool_scopes use the bypass flag below.

OIDC operator setup (Authentik / Keycloak / Zitadel)

Standard OIDC providers compute the JWT scope claim from what the OAuth client requested at the authorization endpoint and ignore property mappings that try to override scope in the authorization_code flow. Property mappings that inject other claim names work fine. To grant per-tool scopes to a Claude.ai or ChatGPT custom connector that doesn't expose scope customization, configure your IdP to return the per-tool scopes under mcp_tool_scopes instead of overriding scope.

ProviderWhere to configure
AuthentikCustomization → Property Mappings → new "Scope Mapping" returning {"mcp_tool_scopes": "tool:foo:read tool:bar:write"}; bind to the OAuth2/OpenID provider
Keycloak (< 26.5)Client → Client Scopes → Mappers → new "Hardcoded claim" or "Script Mapper" emitting mcp_tool_scopes
ZitadelProject → Roles + Action returning {"mcp_tool_scopes": "..."} from a pre-token script

Keycloak ≥ 26.5 ships native MCP integration support; check its release notes before falling back to a custom claim.

Bypass flag

For environments where no custom claim can be injected (managed services, restricted IdPs), set MCP_AUTH_DISABLE_SCOPE_CHECKS=true to bypass scope enforcement entirely.

VariableDefaultEffect
MCP_AUTH_DISABLE_SCOPE_CHECKSfalseWhen true, both withRequiredScopes (declared auth: [...]) and checkScopes (runtime-computed scopes inside handlers) early-return after the auth-context presence check. Token signature, audience, issuer, and expiry validation remain intact.

The flag bypasses both declared auth: [...] enforcement and runtime checkScopes calls — including tenant isolation patterns like team:${input.teamId}:write. Naming is deliberate: this disables all scope checks, not just per-tool ones. Applies to MCP_AUTH_MODE=jwt and MCP_AUTH_MODE=oauth (no effect under none).

A WARNING-level log is emitted at startup whenever the flag is active so operators don't lose track of it. Combine with server-side ACLs (path filters, allowlists, tenant rules) — without an in-handler ACL, every authenticated user effectively has every scope.


Endpoints

EndpointProtected
GET /healthzNo
GET /mcpNo
POST /mcpYes (when auth enabled)
DELETE /mcpYes (when auth enabled) — session termination
OPTIONS /mcpNo (handled by CORS middleware before auth)

CORS: Set MCP_ALLOWED_ORIGINS to a comma-separated list of allowed origins, or * for open access.

Stdio mode: No HTTP auth layer. Authorization is handled entirely by the host process.


Multi-tenancy

ctx.state is automatically scoped to the current tenant — no manual key prefixing needed.

tenantId sources

ModeSourceValue
Stdio (any auth mode)Hardcoded default'default'
HTTP + MCP_AUTH_MODE=noneHardcoded default'default' (single-tenant by design)
HTTP + MCP_AUTH_MODE=jwt/oauthJWT tid claimAuto-propagated from token; undefined if absent (fail-closed)

Tenant ID validation rules

  • Max 128 characters
  • Characters: alphanumeric, hyphens, underscores, dots
  • Must start and end with an alphanumeric character
  • No path traversal sequences (../)
  • No consecutive dots (..)

Using ctx.state

handler: async (input, ctx) => {
  // Automatically scoped to ctx.tenantId — no manual prefixing
  await ctx.state.set('item:123', { name: 'Widget', count: 42 });
  const item = await ctx.state.get<Item>('item:123');
  await ctx.state.delete('item:123');

  const page = await ctx.state.list('item:', { cursor, limit: 20 });
  // page: { items: Array<{ key, value }>, cursor?: string }
},

ctx.state throws McpError(InvalidRequest) if tenantId is missing. Stdio (any auth mode) and HTTP+MCP_AUTH_MODE=none default tenantId to 'default' so ctx.state works without forcing operators to mint tokens. HTTP+jwt/oauth deliberately fails closed when the token lacks a tid claim — distinct authenticated callers must not silently share state.


Auth context shape

Available on ctx.auth inside handlers (when auth is enabled):

interface AuthContext {
  clientId: string;        // Required — 'cid' or 'client_id' JWT claim
  scopes: string[];        // Required — union of 'scp', 'scope', and 'mcp_tool_scopes' claims
  sub: string;             // Required — 'sub' claim; falls back to clientId when absent
  token?: string;          // Optional — raw JWT or OAuth bearer token string (present when transport provides it)
  tenantId?: string;       // Optional — 'tid' claim; present only for multi-tenant tokens
}

Access directly for conditional logic:

handler: async (input, ctx) => {
  const isAdmin = ctx.auth?.scopes.includes('admin:write') ?? false;
  // ...
},

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.