[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"skill:01M6GVBCVSS4ATCB0ANVE6GZ1B":3,"skill-same:01M6GVBCVSS4ATCB0ANVE6GZ1B":100},{"skillNo":4,"name":5,"nameZh":6,"description":7,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":16,"sourceHtmlUrl":17,"categories":18,"keywords":27,"publishedAt":53,"linkOnly":54,"markdownContent":55,"markdownBody":56,"frontMatter":57,"refName":63,"commitSha":64,"skillDirectory":65,"forksCount":66,"repositoryHtmlUrl":67,"repositoryHomepageUrl":6,"ownerHtmlUrl":68,"ownerBlogUrl":69,"ownerDescription":70,"updatedAt":71,"install":72,"resources":85},"01M6GVBCVSS4ATCB0ANVE6GZ1B","api-auth",null,"Authentication, authorization, and multi-tenancy patterns for `@cyanheads\u002Fmcp-ts-core`. Use when implementing auth scopes on tools\u002Fresources, configuring auth modes (none\u002Fjwt\u002Foauth), working with JWT\u002FOAuth env vars, or understanding how tenantId flows through ctx.state.","jiayaoqijia\u002Fcryptoskill","jiayaoqijia","https:\u002F\u002Favatars.githubusercontent.com\u002Fu\u002F1453879?v=4","01M6GMYTX7Q7S504VC6E756ENR","YQ","01M6GMYV1QKJ1CC75MTTGT9PR0",81,"Apache-2.0","https:\u002F\u002Fgithub.com\u002Fjiayaoqijia\u002Fcryptoskill\u002Fblob\u002Fmain\u002Fskills\u002Fmcp-servers\u002Fcyanheads-api-auth\u002FLICENSE","https:\u002F\u002Fgithub.com\u002Fjiayaoqijia\u002Fcryptoskill\u002Fblob\u002Fmain\u002Fskills\u002Fmcp-servers\u002Fcyanheads-api-auth\u002FSKILL.md",[19,24],{"categoryNo":20,"categoryName":21,"taxonomyType":22,"taxonomyTypeDesc":23},"01JBSEEDCAT000000000000010","后端与接口","SCENARIO","使用场景",{"categoryNo":25,"categoryName":26,"taxonomyType":22,"taxonomyTypeDesc":23},"01JBSEEDCAT000000000000023","Skill与智能体开发",[28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52],"auth","api auth","api","when","MCP","ctx","modes","for","implementing","with","flows","multi","resources","env","multi-tenancy","patterns","reference","use","how","jwt","OAuth","env vars","state","authentication","core","2026-11-10 10:12:30",false,"---\nname: api-auth\ndescription: >\n  Authentication, authorization, and multi-tenancy patterns for `@cyanheads\u002Fmcp-ts-core`. Use when implementing auth scopes on tools\u002Fresources, configuring auth modes (none\u002Fjwt\u002Foauth), working with JWT\u002FOAuth env vars, or understanding how tenantId flows through ctx.state.\nmetadata:\n  author: cyanheads\n  version: \"1.6\"\n  audience: external\n  type: reference\n---\n\n## Overview\n\nThe 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\u002Fcatch or manual scope checking required for the common case.\n\n---\n\n## Inline auth (primary pattern)\n\nDeclare 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`.\n\n```ts\nimport { tool } from '@cyanheads\u002Fmcp-ts-core';\n\nconst myTool = tool('my_tool', {\n  input: z.object({ query: z.string().describe('Search query') }),\n  output: z.object({ result: z.string().describe('Search result') }),\n  auth: ['tool:my_tool:read'],\n  async handler(input, ctx) {\n    \u002F\u002F Only reached if caller has 'tool:my_tool:read' scope\n  },\n});\n```\n\nWhen `MCP_AUTH_MODE=none`, auth checks are skipped and defaults are allowed.\n\nA failed check returns `Forbidden` (-32005, `Insufficient permissions.`) or, when auth is enabled but the request carries no auth context, `Unauthorized` (-32006). Neither carries `data`: the required, granted, and missing scope names stay in the server log, so a caller cannot enumerate scopes from the error.\n\nThe scope check logs a missing scope at `warning`, naming the missing scopes. On a tool, the call's `Error in tool:\u003Cname>` record follows at `notice` with no stack — the refusal is the caller's standing, not a fault in this server — and `mcp.errors.classified` counts it with `mcp.error.severity: \"notice\"`. That holds for the inline check and for `checkScopes` in a handler alike, and the level is fixed: the refusal carries no `data.reason`, so no `errors[]` entry can set it. A missing auth context keeps its `Error in tool:` record at `error`, and so does a `forbidden()` the handler throws itself. A resource read the inline check refuses writes only the scope check's own records.\n\n---\n\n## Dynamic auth\n\nFor runtime-computed scopes (e.g., scopes that depend on input values like a team or resource ID), use `checkScopes` from `@cyanheads\u002Fmcp-ts-core\u002Fauth` inside the handler:\n\n```ts\nimport { checkScopes } from '@cyanheads\u002Fmcp-ts-core\u002Fauth';\n\nhandler: async (input, ctx) => {\n  checkScopes(ctx, [`team:${input.teamId}:write`]);\n  \u002F\u002F Continues only if scope is satisfied\n},\n```\n\n**Signature:** `checkScopes(ctx: Context, requiredScopes: string[]): void`\n\n**Throws:**\n\n- `McpError(Forbidden)` — auth is active and one or more required scopes are missing\n- `McpError(Unauthorized)` — auth is enabled but no auth context exists on the request\n- No-ops when `MCP_AUTH_MODE=none`\n\n---\n\n## Auth modes\n\nSet via `MCP_AUTH_MODE` environment variable.\n\n| Mode | Value | Behavior |\n|:-----|:------|:---------|\n| Disabled | `none` | No auth enforcement. All requests allowed. |\n| JWT | `jwt` | Local secret verification via `MCP_AUTH_SECRET_KEY`. Requires explicit `DEV_MCP_AUTH_BYPASS=true` to bypass in development. |\n| OAuth | `oauth` | JWKS verification against an external issuer. |\n\n### JWT config\n\n| Variable | Required | Purpose |\n|:---------|:---------|:--------|\n| `MCP_AUTH_SECRET_KEY` | Yes (unless bypass) | Signing secret for HS256 JWT verification. Must be ≥ 32 characters. |\n| `DEV_MCP_AUTH_BYPASS` | No | Set to `true` to skip JWT verification in development. Blocked in `NODE_ENV=production`. |\n| `DEV_MCP_CLIENT_ID` | No | Client ID injected when bypass is active (default: `'dev-client-id'`). |\n| `DEV_MCP_SCOPES` | No | Comma-separated scopes injected when bypass is active (default: `['dev-scope']`). |\n\n**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.\n\n### OAuth config\n\n| Variable | Required | Purpose |\n|:---------|:---------|:--------|\n| `OAUTH_ISSUER_URL` | Yes | Token issuer URL (used for JWKS discovery) |\n| `OAUTH_AUDIENCE` | Yes | Expected `aud` claim value |\n| `OAUTH_JWKS_URI` | No | Override JWKS endpoint (defaults to `{issuer}\u002F.well-known\u002Fjwks.json`) |\n| `MCP_SERVER_RESOURCE_IDENTIFIER` | No | RFC 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. |\n\n### JWT claims mapping\n\n| Claim | JWT Field | Purpose |\n|:------|:----------|:--------|\n| `clientId` | `cid` \u002F `client_id` | Identifies the calling client |\n| `scopes` | union of `scp`, `scope`, `mcp_tool_scopes` | Granted scope list (see below) |\n| `sub` | `sub` | Subject (user or service identity) |\n| `tenantId` | `tid` | Tenant identifier — drives `ctx.state` scoping |\n\n`scopes` is the **union** of three claims, in this order:\n\n| Claim | Form | Source |\n|:------|:-----|:-------|\n| `scp` | array of strings | Okta-style |\n| `scope` | space-delimited string | OAuth 2.1 \u002F OIDC standard |\n| `mcp_tool_scopes` | array of strings **or** space-delimited string | Custom claim for OIDC providers that cannot inject scopes into `scope` during the `authorization_code` flow (Authentik, Keycloak \u003C 26.5, Zitadel) |\n\nAuth0\u002FOkta-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.\n\n### OIDC operator setup (Authentik \u002F Keycloak \u002F Zitadel)\n\nStandard 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`.\n\n| Provider | Where to configure |\n|:---------|:--------------------|\n| Authentik | Customization → Property Mappings → new \"Scope Mapping\" returning `{\"mcp_tool_scopes\": \"tool:foo:read tool:bar:write\"}`; bind to the OAuth2\u002FOpenID provider |\n| Keycloak (\u003C 26.5) | Client → Client Scopes → Mappers → new \"Hardcoded claim\" or \"Script Mapper\" emitting `mcp_tool_scopes` |\n| Zitadel | Project → Roles + Action returning `{\"mcp_tool_scopes\": \"...\"}` from a pre-token script |\n\nKeycloak ≥ 26.5 ships native MCP integration support; check its release notes before falling back to a custom claim.\n\n### Bypass flag\n\nFor environments where no custom claim can be injected (managed services, restricted IdPs), set `MCP_AUTH_DISABLE_SCOPE_CHECKS=true` to bypass scope enforcement entirely.\n\n| Variable | Default | Effect |\n|:---------|:--------|:-------|\n| `MCP_AUTH_DISABLE_SCOPE_CHECKS` | `false` | When `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. |\n\nThe 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`).\n\nA `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.\n\n---\n\n## Endpoints\n\n| Endpoint | Protected |\n|:---------|:----------|\n| `GET \u002Fhealthz` | No |\n| `GET \u002Fmcp` | No |\n| `POST \u002Fmcp` | Yes (when auth enabled) |\n| `DELETE \u002Fmcp` | Yes (when auth enabled) — session termination |\n| `OPTIONS \u002Fmcp` | No (handled by CORS middleware before auth) |\n\n**CORS:** Set `MCP_ALLOWED_ORIGINS` to a comma-separated list of allowed origins, or `*` for open access. Left unset, only loopback browser origins reach the endpoint. The preflight for an accepted origin allows every request header the server reads: `Content-Type`, `Authorization`, `Mcp-Session-Id`, `MCP-Protocol-Version`, the 2026-07-28 `Mcp-Method` and `Mcp-Name`, `Last-Event-ID` (SSE resume), and one `Mcp-Param-\u003CName>` per `headerParam` designation on a registered tool — derived from the tool definitions, nothing to configure. Any other origin gets the first four only, so it learns no designation names.\n\n**Stdio mode:** No HTTP auth layer. Authorization is handled entirely by the host process.\n\n---\n\n## Multi-tenancy\n\n`ctx.state` is automatically scoped to the current tenant — no manual key prefixing needed.\n\n### tenantId sources\n\n| Mode | Source | Value |\n|:-----|:-------|:------|\n| Stdio (any auth mode) | Hardcoded default | `'default'` |\n| HTTP + `MCP_AUTH_MODE=none` | Hardcoded default | `'default'` (single-tenant by design) |\n| HTTP + `MCP_AUTH_MODE=jwt`\u002F`oauth` | JWT `tid` claim | Auto-propagated from token; `undefined` if absent (fail-closed) |\n\n### Tenant ID validation rules\n\n- Max 128 characters\n- Characters: alphanumeric, hyphens, underscores, dots\n- Must start and end with an alphanumeric character\n- No path traversal sequences (`..\u002F`)\n- No consecutive dots (`..`)\n\n### Using `ctx.state`\n\n```ts\nhandler: async (input, ctx) => {\n  \u002F\u002F Automatically scoped to ctx.tenantId — no manual prefixing\n  await ctx.state.set('item\u002F123', { name: 'Widget', count: 42 });\n  const item = await ctx.state.get\u003CItem>('item\u002F123');\n  await ctx.state.delete('item\u002F123');\n\n  const page = await ctx.state.list('item\u002F', { cursor, limit: 20 });\n  \u002F\u002F page: { items: Array\u003C{ key, value }>, cursor?: string }\n},\n```\n\n`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`\u002F`oauth` deliberately fails closed when the token lacks a `tid` claim — distinct authenticated callers must not silently share state.\n\n---\n\n## Auth context shape\n\nAvailable on `ctx.auth` inside handlers (when auth is enabled):\n\n```ts\ninterface AuthContext {\n  clientId: string;        \u002F\u002F Required — 'cid' or 'client_id' JWT claim\n  scopes: string[];        \u002F\u002F Required — union of 'scp', 'scope', and 'mcp_tool_scopes' claims\n  sub: string;             \u002F\u002F Required — 'sub' claim; falls back to clientId when absent\n  token?: string;          \u002F\u002F Optional — raw JWT or OAuth bearer token string (present when transport provides it)\n  tenantId?: string;       \u002F\u002F Optional — 'tid' claim; present only for multi-tenant tokens\n}\n```\n\nAccess directly for conditional logic:\n\n```ts\nhandler: async (input, ctx) => {\n  const isAdmin = ctx.auth?.scopes.includes('admin:write') ?? false;\n  \u002F\u002F ...\n},\n```\n","## Overview\n\nThe 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\u002Fcatch or manual scope checking required for the common case.\n\n---\n\n## Inline auth (primary pattern)\n\nDeclare 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`.\n\n```ts\nimport { tool } from '@cyanheads\u002Fmcp-ts-core';\n\nconst myTool = tool('my_tool', {\n  input: z.object({ query: z.string().describe('Search query') }),\n  output: z.object({ result: z.string().describe('Search result') }),\n  auth: ['tool:my_tool:read'],\n  async handler(input, ctx) {\n    \u002F\u002F Only reached if caller has 'tool:my_tool:read' scope\n  },\n});\n```\n\nWhen `MCP_AUTH_MODE=none`, auth checks are skipped and defaults are allowed.\n\nA failed check returns `Forbidden` (-32005, `Insufficient permissions.`) or, when auth is enabled but the request carries no auth context, `Unauthorized` (-32006). Neither carries `data`: the required, granted, and missing scope names stay in the server log, so a caller cannot enumerate scopes from the error.\n\nThe scope check logs a missing scope at `warning`, naming the missing scopes. On a tool, the call's `Error in tool:\u003Cname>` record follows at `notice` with no stack — the refusal is the caller's standing, not a fault in this server — and `mcp.errors.classified` counts it with `mcp.error.severity: \"notice\"`. That holds for the inline check and for `checkScopes` in a handler alike, and the level is fixed: the refusal carries no `data.reason`, so no `errors[]` entry can set it. A missing auth context keeps its `Error in tool:` record at `error`, and so does a `forbidden()` the handler throws itself. A resource read the inline check refuses writes only the scope check's own records.\n\n---\n\n## Dynamic auth\n\nFor runtime-computed scopes (e.g., scopes that depend on input values like a team or resource ID), use `checkScopes` from `@cyanheads\u002Fmcp-ts-core\u002Fauth` inside the handler:\n\n```ts\nimport { checkScopes } from '@cyanheads\u002Fmcp-ts-core\u002Fauth';\n\nhandler: async (input, ctx) => {\n  checkScopes(ctx, [`team:${input.teamId}:write`]);\n  \u002F\u002F Continues only if scope is satisfied\n},\n```\n\n**Signature:** `checkScopes(ctx: Context, requiredScopes: string[]): void`\n\n**Throws:**\n\n- `McpError(Forbidden)` — auth is active and one or more required scopes are missing\n- `McpError(Unauthorized)` — auth is enabled but no auth context exists on the request\n- No-ops when `MCP_AUTH_MODE=none`\n\n---\n\n## Auth modes\n\nSet via `MCP_AUTH_MODE` environment variable.\n\n| Mode | Value | Behavior |\n|:-----|:------|:---------|\n| Disabled | `none` | No auth enforcement. All requests allowed. |\n| JWT | `jwt` | Local secret verification via `MCP_AUTH_SECRET_KEY`. Requires explicit `DEV_MCP_AUTH_BYPASS=true` to bypass in development. |\n| OAuth | `oauth` | JWKS verification against an external issuer. |\n\n### JWT config\n\n| Variable | Required | Purpose |\n|:---------|:---------|:--------|\n| `MCP_AUTH_SECRET_KEY` | Yes (unless bypass) | Signing secret for HS256 JWT verification. Must be ≥ 32 characters. |\n| `DEV_MCP_AUTH_BYPASS` | No | Set to `true` to skip JWT verification in development. Blocked in `NODE_ENV=production`. |\n| `DEV_MCP_CLIENT_ID` | No | Client ID injected when bypass is active (default: `'dev-client-id'`). |\n| `DEV_MCP_SCOPES` | No | Comma-separated scopes injected when bypass is active (default: `['dev-scope']`). |\n\n**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.\n\n### OAuth config\n\n| Variable | Required | Purpose |\n|:---------|:---------|:--------|\n| `OAUTH_ISSUER_URL` | Yes | Token issuer URL (used for JWKS discovery) |\n| `OAUTH_AUDIENCE` | Yes | Expected `aud` claim value |\n| `OAUTH_JWKS_URI` | No | Override JWKS endpoint (defaults to `{issuer}\u002F.well-known\u002Fjwks.json`) |\n| `MCP_SERVER_RESOURCE_IDENTIFIER` | No | RFC 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. |\n\n### JWT claims mapping\n\n| Claim | JWT Field | Purpose |\n|:------|:----------|:--------|\n| `clientId` | `cid` \u002F `client_id` | Identifies the calling client |\n| `scopes` | union of `scp`, `scope`, `mcp_tool_scopes` | Granted scope list (see below) |\n| `sub` | `sub` | Subject (user or service identity) |\n| `tenantId` | `tid` | Tenant identifier — drives `ctx.state` scoping |\n\n`scopes` is the **union** of three claims, in this order:\n\n| Claim | Form | Source |\n|:------|:-----|:-------|\n| `scp` | array of strings | Okta-style |\n| `scope` | space-delimited string | OAuth 2.1 \u002F OIDC standard |\n| `mcp_tool_scopes` | array of strings **or** space-delimited string | Custom claim for OIDC providers that cannot inject scopes into `scope` during the `authorization_code` flow (Authentik, Keycloak \u003C 26.5, Zitadel) |\n\nAuth0\u002FOkta-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.\n\n### OIDC operator setup (Authentik \u002F Keycloak \u002F Zitadel)\n\nStandard 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`.\n\n| Provider | Where to configure |\n|:---------|:--------------------|\n| Authentik | Customization → Property Mappings → new \"Scope Mapping\" returning `{\"mcp_tool_scopes\": \"tool:foo:read tool:bar:write\"}`; bind to the OAuth2\u002FOpenID provider |\n| Keycloak (\u003C 26.5) | Client → Client Scopes → Mappers → new \"Hardcoded claim\" or \"Script Mapper\" emitting `mcp_tool_scopes` |\n| Zitadel | Project → Roles + Action returning `{\"mcp_tool_scopes\": \"...\"}` from a pre-token script |\n\nKeycloak ≥ 26.5 ships native MCP integration support; check its release notes before falling back to a custom claim.\n\n### Bypass flag\n\nFor environments where no custom claim can be injected (managed services, restricted IdPs), set `MCP_AUTH_DISABLE_SCOPE_CHECKS=true` to bypass scope enforcement entirely.\n\n| Variable | Default | Effect |\n|:---------|:--------|:-------|\n| `MCP_AUTH_DISABLE_SCOPE_CHECKS` | `false` | When `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. |\n\nThe 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`).\n\nA `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.\n\n---\n\n## Endpoints\n\n| Endpoint | Protected |\n|:---------|:----------|\n| `GET \u002Fhealthz` | No |\n| `GET \u002Fmcp` | No |\n| `POST \u002Fmcp` | Yes (when auth enabled) |\n| `DELETE \u002Fmcp` | Yes (when auth enabled) — session termination |\n| `OPTIONS \u002Fmcp` | No (handled by CORS middleware before auth) |\n\n**CORS:** Set `MCP_ALLOWED_ORIGINS` to a comma-separated list of allowed origins, or `*` for open access. Left unset, only loopback browser origins reach the endpoint. The preflight for an accepted origin allows every request header the server reads: `Content-Type`, `Authorization`, `Mcp-Session-Id`, `MCP-Protocol-Version`, the 2026-07-28 `Mcp-Method` and `Mcp-Name`, `Last-Event-ID` (SSE resume), and one `Mcp-Param-\u003CName>` per `headerParam` designation on a registered tool — derived from the tool definitions, nothing to configure. Any other origin gets the first four only, so it learns no designation names.\n\n**Stdio mode:** No HTTP auth layer. Authorization is handled entirely by the host process.\n\n---\n\n## Multi-tenancy\n\n`ctx.state` is automatically scoped to the current tenant — no manual key prefixing needed.\n\n### tenantId sources\n\n| Mode | Source | Value |\n|:-----|:-------|:------|\n| Stdio (any auth mode) | Hardcoded default | `'default'` |\n| HTTP + `MCP_AUTH_MODE=none` | Hardcoded default | `'default'` (single-tenant by design) |\n| HTTP + `MCP_AUTH_MODE=jwt`\u002F`oauth` | JWT `tid` claim | Auto-propagated from token; `undefined` if absent (fail-closed) |\n\n### Tenant ID validation rules\n\n- Max 128 characters\n- Characters: alphanumeric, hyphens, underscores, dots\n- Must start and end with an alphanumeric character\n- No path traversal sequences (`..\u002F`)\n- No consecutive dots (`..`)\n\n### Using `ctx.state`\n\n```ts\nhandler: async (input, ctx) => {\n  \u002F\u002F Automatically scoped to ctx.tenantId — no manual prefixing\n  await ctx.state.set('item\u002F123', { name: 'Widget', count: 42 });\n  const item = await ctx.state.get\u003CItem>('item\u002F123');\n  await ctx.state.delete('item\u002F123');\n\n  const page = await ctx.state.list('item\u002F', { cursor, limit: 20 });\n  \u002F\u002F page: { items: Array\u003C{ key, value }>, cursor?: string }\n},\n```\n\n`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`\u002F`oauth` deliberately fails closed when the token lacks a `tid` claim — distinct authenticated callers must not silently share state.\n\n---\n\n## Auth context shape\n\nAvailable on `ctx.auth` inside handlers (when auth is enabled):\n\n```ts\ninterface AuthContext {\n  clientId: string;        \u002F\u002F Required — 'cid' or 'client_id' JWT claim\n  scopes: string[];        \u002F\u002F Required — union of 'scp', 'scope', and 'mcp_tool_scopes' claims\n  sub: string;             \u002F\u002F Required — 'sub' claim; falls back to clientId when absent\n  token?: string;          \u002F\u002F Optional — raw JWT or OAuth bearer token string (present when transport provides it)\n  tenantId?: string;       \u002F\u002F Optional — 'tid' claim; present only for multi-tenant tokens\n}\n```\n\nAccess directly for conditional logic:\n\n```ts\nhandler: async (input, ctx) => {\n  const isAdmin = ctx.auth?.scopes.includes('admin:write') ?? false;\n  \u002F\u002F ...\n},\n```\n",{"name":5,"metadata":58,"description":62},{"type":44,"author":59,"version":60,"audience":61},"cyanheads","1.6","external","Authentication, authorization, and multi-tenancy patterns for `@cyanheads\u002Fmcp-ts-core`. Use when implementing auth scopes on tools\u002Fresources, configuring auth modes (none\u002Fjwt\u002Foauth), working with JWT\u002FOAuth env vars, or understanding how tenantId flows through ctx.state.\n","main","3df1d0d67b30ff15236561f8c0f9f06d33f42dfd","skills\u002Fmcp-servers\u002Fcyanheads-api-auth",17,"https:\u002F\u002Fgithub.com\u002Fjiayaoqijia\u002Fcryptoskill","https:\u002F\u002Fgithub.com\u002Fjiayaoqijia","https:\u002F\u002Feth2030.com","Hardcore infra. Building @alt-research and applied cryptography.","2026-11-13 02:31:19",{"source":8,"skillName":5,"commands":73,"prompt":83,"zipAvailable":84},[74,77,80],{"tool":75,"command":76},"npx","npx skills add jiayaoqijia\u002Fcryptoskill --skill api-auth",{"tool":78,"command":79},"bunx","bunx skills add jiayaoqijia\u002Fcryptoskill --skill api-auth",{"tool":81,"command":82},"pnpm","pnpm dlx skills add jiayaoqijia\u002Fcryptoskill --skill api-auth","安装技能，技能地址：https:\u002F\u002Fgithub.com\u002Fjiayaoqijia\u002Fcryptoskill\u002Fblob\u002Fmain\u002Fskills\u002Fmcp-servers\u002Fcyanheads-api-auth\u002FSKILL.md",true,[86,90,96],{"path":87,"resourceType":87,"resourceTypeDesc":88,"sizeBytes":89,"sourceHtmlUrl":16},"LICENSE","许可证",11317,{"path":91,"resourceType":92,"resourceTypeDesc":93,"sizeBytes":94,"sourceHtmlUrl":95},"SOURCE.md","REFERENCE","参考文档",367,"https:\u002F\u002Fgithub.com\u002Fjiayaoqijia\u002Fcryptoskill\u002Fblob\u002Fmain\u002Fskills\u002Fmcp-servers\u002Fcyanheads-api-auth\u002FSOURCE.md",{"path":97,"resourceType":92,"resourceTypeDesc":93,"sizeBytes":98,"sourceHtmlUrl":99},"TRUST.auto.yaml",2217,"https:\u002F\u002Fgithub.com\u002Fjiayaoqijia\u002Fcryptoskill\u002Fblob\u002Fmain\u002Fskills\u002Fmcp-servers\u002Fcyanheads-api-auth\u002FTRUST.auto.yaml",[101,130,150,168,181,191],{"skillNo":102,"name":103,"nameZh":6,"description":104,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":105,"licenseFileUrl":106,"sourceHtmlUrl":107,"categories":108,"keywords":109,"publishedAt":53},"01M712C436EQYE43342Q4E7GG1","recipe-performance","Diagnose a specific MetaMask user flow's real-app performance and Sentry critical-user-flow telemetry with deterministic mm-harness recipes. Use for CUF boundaries, cold\u002Fwarm\u002Fbackground-resume behavior, latency regressions, lifecycle tags, duplicate or blocking network requests, long tasks, layout\u002Fpaint cost, screen redraws, React rerenders, or browser trace analysis where tests alone cannot prove the real app.","MIT","https:\u002F\u002Fgithub.com\u002Fjiayaoqijia\u002Fcryptoskill\u002Fblob\u002Fmain\u002Fskills\u002Fwallets\u002Fmetamask-official-recipe-performance\u002FLICENSE","https:\u002F\u002Fgithub.com\u002Fjiayaoqijia\u002Fcryptoskill\u002Fblob\u002Fmain\u002Fskills\u002Fwallets\u002Fmetamask-official-recipe-performance\u002FSKILL.md",[],[110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129],"performance","recipe performance","recipe","mm","sentry","harness","app","deterministic","recipes","flow","cold","critical","analysis","tasks","react","browser","layout","screen","network","metamask",{"skillNo":131,"name":132,"nameZh":6,"description":133,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":134,"sourceHtmlUrl":135,"categories":136,"keywords":137,"publishedAt":53},"01M70XT2F8A5CQZ81JBP98KSW3","mexc-spot-websocket","MEXC Spot WebSocket workflows for public streams, protobuf handling, depth maintenance, listen keys, and private streams without exposing API keys.","https:\u002F\u002Fgithub.com\u002Fjiayaoqijia\u002Fcryptoskill\u002Fblob\u002Fmain\u002Fskills\u002Fexchanges\u002Fmncrftfrcnm-mexc-spot-websocket\u002FLICENSE","https:\u002F\u002Fgithub.com\u002Fjiayaoqijia\u002Fcryptoskill\u002Fblob\u002Fmain\u002Fskills\u002Fexchanges\u002Fmncrftfrcnm-mexc-spot-websocket\u002FSKILL.md",[],[138,139,140,30,141,142,143,144,145,146,35,147,148,149],"mexc spot websocket","spot","websocket","workflows","public","listen","streams","depth","keys","api keys","maintenance","protobuf",{"skillNo":151,"name":152,"nameZh":6,"description":153,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":154,"sourceHtmlUrl":155,"categories":156,"keywords":157,"publishedAt":53},"01M70XT0MBXN3WZY1BKN0CPNKR","mexc-spot-rest","MEXC Spot REST workflows for public data, signed account requests, orders, wallet\u002Fadmin actions, and secure signing without exposing API keys.","https:\u002F\u002Fgithub.com\u002Fjiayaoqijia\u002Fcryptoskill\u002Fblob\u002Fmain\u002Fskills\u002Fexchanges\u002Fmncrftfrcnm-mexc-spot-rest\u002FLICENSE","https:\u002F\u002Fgithub.com\u002Fjiayaoqijia\u002Fcryptoskill\u002Fblob\u002Fmain\u002Fskills\u002Fexchanges\u002Fmncrftfrcnm-mexc-spot-rest\u002FSKILL.md",[],[158,139,159,160,161,162,163,147,35,146,164,165,166,142,167,141,30],"mexc spot rest","REST","admin","signing","Wallet","requests","actions","secure","data","orders",{"skillNo":169,"name":170,"nameZh":6,"description":171,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":172,"sourceHtmlUrl":173,"categories":174,"keywords":175,"publishedAt":53},"01M70XSYSVZ2N0N91XCAR2CAK0","mexc-futures-websocket","MEXC Futures WebSocket workflows for public streams, private account\u002Forder\u002Fposition streams, ping\u002Fpong, subscriptions, and depth maintenance.","https:\u002F\u002Fgithub.com\u002Fjiayaoqijia\u002Fcryptoskill\u002Fblob\u002Fmain\u002Fskills\u002Fexchanges\u002Fmncrftfrcnm-mexc-futures-websocket\u002FLICENSE","https:\u002F\u002Fgithub.com\u002Fjiayaoqijia\u002Fcryptoskill\u002Fblob\u002Fmain\u002Fskills\u002Fexchanges\u002Fmncrftfrcnm-mexc-futures-websocket\u002FSKILL.md",[],[176,177,140,178,179,148,35,180,145,144,142,141],"mexc futures websocket","futures","position","ping","subscriptions",{"skillNo":182,"name":183,"nameZh":6,"description":6,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":105,"licenseFileUrl":184,"sourceHtmlUrl":185,"categories":186,"keywords":187,"publishedAt":53},"01M6H0ZWHVE5W04WG2DKQE2SQC","0xklen-version-and-refresh-sanctions-lists","https:\u002F\u002Fgithub.com\u002Fjiayaoqijia\u002Fcryptoskill\u002Fblob\u002Fmain\u002Fskills\u002Fpayments\u002F0xklen-version-and-refresh-sanctions-lists\u002FLICENSE","https:\u002F\u002Fgithub.com\u002Fjiayaoqijia\u002Fcryptoskill\u002Fblob\u002Fmain\u002Fskills\u002Fpayments\u002F0xklen-version-and-refresh-sanctions-lists\u002FSKILL.md",[],[188,189,190],"refresh","0xklen version and refresh sanctions lists","version",{"skillNo":192,"name":193,"nameZh":6,"description":194,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":105,"licenseFileUrl":195,"sourceHtmlUrl":196,"categories":197,"keywords":198,"publishedAt":53},"01M6H0Z28Q3R8J0S799GJQWXM3","verify-tests-cover-changed-lines","Use when a PR adds code that the existing suite may not exercise. Measures patch coverage — the share of newly changed lines hit by tests — before approving.","https:\u002F\u002Fgithub.com\u002Fjiayaoqijia\u002Fcryptoskill\u002Fblob\u002Fmain\u002Fskills\u002Fpayments\u002F0xklen-verify-tests-cover-changed-lines\u002FLICENSE","https:\u002F\u002Fgithub.com\u002Fjiayaoqijia\u002Fcryptoskill\u002Fblob\u002Fmain\u002Fskills\u002Fpayments\u002F0xklen-verify-tests-cover-changed-lines\u002FSKILL.md",[],[199,200,201,202,203,204,205,45,206,207,208],"tests","cover","verify tests cover changed lines","verify","coverage","pr","exercise","patch","code","share"]