Skip to main content

Protect your MCP server

An MCP server exposes tools — functions an AI client can call. The moment those tools do anything that matters (read a customer record, open a pull request, charge a card) you have an authorization problem: any client that can reach the endpoint can call any tool. MCP itself has no notion of who is calling or what they're allowed to do.

This guide puts that missing layer in front of a Go MCP server. By the end, every tool call is authenticated with an OAuth token, authorized against a per-tool scope policy you control from a dashboard, and observable — and you will have written no token-parsing, JWKS-fetching, or policy code. One function call, MountMCP, installs all of it.

Time: ~15 minutes to a launched, protected server.


What you have before and after

BEFORE                                AFTER
────── ─────
POST /mcp tools/call ──▶ runs POST /mcp (no token) ──▶ 401 + how-to-auth
POST /mcp (bad token) ──▶ 401 (or in-band error)
anyone on the network POST /mcp (missing scope) ──▶ 403 for that tool
can call every tool POST /mcp (valid + scoped)──▶ tool runs ✅

+ your tools listed in the dashboard
+ admins assign per-tool permissions there
+ policy changes live in ~5 min, no redeploy

Nothing about your MCP handler changes. The SDK wraps it; your tool code stays a plain MCP handler that only ever runs for authorized calls.

Architecture: where the SDK sits

AuthSec splits into two roles that map onto the OAuth model:

  • The authorization server (AuthSec). Issues tokens, runs the login and consent flow, exposes JWKS and introspection, and holds the authoritative scope matrix (which tool needs which scope). You never run this — it's the AuthSec service.
  • The resource server (your MCP server). Holds the tools and validates incoming tokens. This is where the SDK runs, as a thin wrapper around your handler.
   AI client ──POST /mcp (Bearer token)──▶  your MCP server
(Claude, Cursor, ┌────────────────────────────┐
Codex, an agent) │ go-sdk (MountMCP wrapper) │
▲ │ validate → authorize → run │
│ 401 points here └─────────────┬──────────────┘
│ │ validate token,
▼ │ fetch scope policy
AuthSec (authorization server) ◀──────────────────────┘
token endpoint · JWKS · introspection · scope matrix · consent

The wrapper talks to AuthSec over HTTPS to verify tokens and fetch policy; your handler never does.

The request lifecycle, in detail

This is what actually happens on every call once the SDK is mounted. Reading it once makes the rest of the guide — and every error you might hit — obvious. The code path is runtime.go's Wrap, backed by validator.go.

POST /mcp  { "jsonrpc":"2.0", "method":"tools/call", "params":{"name":"add_no"} }
Authorization: Bearer eyJ...


┌───────────────────────────────────────────────────────────────────────┐
│ 0. Metadata short-circuit │
│ If the path is the RFC 9728 metadata path, serve the PRM doc and │
│ stop. (MountMCP registers this route for you.) │
├───────────────────────────────────────────────────────────────────────┤
│ 1. Parse the bearer token │
│ Missing header → empty token (flows to the denial path). │
│ Malformed "Authorization" value → 400. │
├───────────────────────────────────────────────────────────────────────┤
│ 2. Validate the token → Principal │
│ (a) JWT signature: RS256, verified against AuthSec's JWKS (keys │
│ cached by kid). iss must equal cfg.Issuer; exp is enforced. │
│ (b) Revocation: live introspection (POST /oauth/introspect, Basic │
│ auth with the introspection credentials). Catches tokens │
│ revoked seconds ago that still look valid. │
│ (c) Audience: the token's aud (or resource claim) must include │
│ cfg.ResourceURI — a token minted for another server is rejected.│
│ Any failure → nil principal → denial path (step 5). │
├───────────────────────────────────────────────────────────────────────┤
│ 3. Parse the MCP body (fail-closed) │
│ A non-empty body that isn't valid JSON-RPC → 400, handler never │
│ runs. Handshake/empty bodies pass straight through. │
├───────────────────────────────────────────────────────────────────────┤
│ 4. Authorize the tool (tools/call only) │
│ Look the tool up in the scope matrix (fetched from AuthSec, cached │
│ ~5 min): │
│ • tool not in the matrix → deny (fail-closed) │
│ • tool public (empty scopes) → allow │
│ • tool scoped → allow iff the token has ANY of │
│ the required scopes │
│ tools/list is handled differently: the response is filtered so the │
│ caller only sees tools its scopes permit. │
├───────────────────────────────────────────────────────────────────────┤
│ 5. Allow → your handler runs. Deny → structured error (next section).│
└───────────────────────────────────────────────────────────────────────┘

What a denial looks like (and why it's not always a raw 401)

The SDK is MCP-client-aware (ported from the Python/TS runtimes in mcp_inband.go). How a denial is delivered depends on the caller:

  • No token, or a non-JSON-RPC body → a classic HTTP challenge: 401 Unauthorized with a WWW-Authenticate: Bearer resource_metadata="…" header pointing the client at the login flow, or 403 for a scope failure.
  • A token is present and the body is JSON-RPC → the denial is returned in-band: HTTP 200 with a JSON-RPC error (or a tools/call result with isError: true) carrying a _meta.authsec / data.authsec object (error, status, required_scopes, granted_scopes, tool) and a plain-English message. This is deliberate: an MCP client that gets a raw 401 mid-session often drops the whole session, whereas an in-band error lets the model read "tool X needs scope Y; ask an admin" and react. The JSON-RPC error codes are -32003 for a 403 and -32001 for a 401.
  • MCP handshake methods (initialize, notifications/initialized, ping) pass through even with an invalid token, so a session survives a mid-session token expiry instead of breaking on the next keep-alive.
  • Policy backend unreachable (PolicyModeRemoteRequired and AuthSec is down) → 503, never a silent allow. Failing closed is the point.

You don't choose between these — the SDK picks the right one per request. What matters is knowing that a 200 with isError:true is a denial.

Two things that happen once, at startup

MountMCP / NewRuntime also kick off two background actions the first time they run:

  • PRM publishing. The SDK serves an RFC 9728 protected-resource metadata document at /.well-known/oauth-protected-resource/mcp. Clients fetch it to discover which authorization server to get a token from. You never write this endpoint; MountMCP registers it.
  • Manifest publishing (when PublishManifest is true). The SDK performs a synthetic tools/list against your un-wrapped handler, then PUTs the resulting tool inventory to /authsec/resource-servers/<ResourceServerID>/sdk-manifest (authenticated with your introspection credentials). This is a one-way push so the dashboard can show your tools by name and let admins map scopes to them. It is best-effort: a publish failure is logged and never blocks startup or request handling.

Prerequisites

  • An AuthSec workspace on https://mcpauthz.com.
  • Go ≥ 1.24 and the SDK:
    go get github.com/authsec-ai/sdk-authsec/packages/go-sdk
    One dependency (github.com/golang-jwt/jwt/v5); no CGO.
  • A public URL for your server — the AI client and AuthSec must both reach it. For local development use an ngrok tunnel (ngrok http 8000https://xxxx.ngrok-free.app).

Step 1 — Sign in to the dashboard

Go to https://mcpauthz.com and sign in (or create a workspace) with your work email:

AuthSec sign-in

You land on the workspace dashboard. The tiles show your protected applications; the quick-start cards track your setup — "Wrap your first MCP server" is the one this guide completes:

Dashboard home

Dashboard vocabulary. AuthSec calls a protected MCP server an Application. In OAuth terms it's a resource server — the same thing, friendlier name. The left sidebar groups everything: Applications (your MCP servers), Service Accounts and Agents (things that call them — the M2M and delegation guides), and Roles / Scopes / Assignments (who may call what).

Step 2 — Create the application

Open Applications in the sidebar — it lists every protected MCP server with its readiness and risk state. Click + Create application:

Applications list

2a. Define the protected endpoint

Create application — protected endpoint

Three fields:

  • Application name — a human label, e.g. my-mcp-server.
  • Public base URL — where your server is reachable, e.g. https://xxxx.ngrok-free.app.
  • Protected path — the MCP endpoint path, typically /mcp.

The Resource URI preview shows the combination (https://xxxx.ngrok-free.app/mcp). This URI is the anchor for everything: it becomes the token audience, so it must exactly match the URL clients call (scheme, host, path). Tokens are bound to it via RFC 8707 resource indicators — this is the audience check from step 2 of the request lifecycle, and it's what stops a token issued for someone else's server from working against yours.

When you submit, the dashboard generates a one-time introspection secret, creates canonical scopes from your chosen preset, probes your protected-resource metadata, imports your tools from the manifest, and creates an empty viewer role to hang scopes on.

2b. Pick the access vocabulary (scope preset)

Scroll to Access vocabulary — this generates the scope strings your server will use. For most MCP servers pick Read + Write (marked BEST); with an app namespace of my_mcp it generates four scopes:

Access vocabulary presets

my_mcp:read   my_mcp:write   my_mcp:tools:read   my_mcp:tools:write

⚠️ Creating scopes defines the words. They grant nothing until you assign them to a role (steps 7–8). A scope no role holds is a scope no caller can get.

Click Create and protect:

Create and protect

2c. Grab your credentials from the Setup tab

You land on the application's detail page. The tabs — Overview · Setup · Tools · Scopes · Roles · Access · Clients · Test · Monitor — are mission control for this server. Open Setup, pick Go and .env:

Application setup tab

The page renders copy-paste-ready environment values for this exact application, including the introspection credentials:

Setup tab — generated .env block and verify checks

🔑 The introspection secret is shown once. Copy it now; if you lose it, rotate it from this page. Never commit it.

Below the config, the Verify protection panel shows two checks — Bearer challenge and SDK manifest published — both PENDING until your server runs. You'll return here in step 6.

Step 3 — Configure the environment

Paste the Setup-tab values into a .env next to your server. The SDK reads them with FromEnv():

# Who issues tokens (the authorization server)
AUTHSEC_ISSUER=https://mcpauthz.com
AUTHSEC_AUTHORIZATION_SERVER=https://mcpauthz.com
AUTHSEC_JWKS_URL=https://mcpauthz.com/oauth/jwks
AUTHSEC_INTROSPECTION_URL=https://mcpauthz.com/oauth/introspect

# This application's identity (unique per application — from the Setup tab)
AUTHSEC_RESOURCE_SERVER_ID=<resource-server-uuid>
AUTHSEC_INTROSPECTION_CLIENT_ID=<resource-server-uuid>
AUTHSEC_INTROSPECTION_CLIENT_SECRET=<sec_...>

# Your server's public identity — must match the Resource URI from step 2a
AUTHSEC_RESOURCE_URI=https://xxxx.ngrok-free.app/mcp
AUTHSEC_RESOURCE_NAME=my-mcp-server

# Recommended production posture
AUTHSEC_POLICY_MODE=remote_required # fail CLOSED if policy is unavailable
AUTHSEC_VALIDATION_MODE=jwt_and_introspect # signature check + live revocation check
AUTHSEC_PUBLISH_MANIFEST=true # keep the tool inventory synced to the dashboard

Why these two settings are the production default:

  • remote_required — if the server can't fetch the tool→scope policy from AuthSec, it denies every tool call (503) rather than guessing. The alternative, failing open, means a network blip silently disables your authorization. (For local onboarding you can relax this — see PolicyMode.)
  • jwt_and_introspect — a JWT signature proves the token was minted by AuthSec and hasn't expired, but it cannot tell you the token was revoked five minutes ago. Introspection asks AuthSec live. Belt and suspenders; it's the default when both JWKS_URL and INTROSPECTION_URL are set.

FromEnv() reads, it does not validate. It only parses the AUTHSEC_* variables into a Config. MountMCP / NewRuntime run cfg.Validate() and fail loudly with the exact missing field. FromEnv also accepts the legacy aliases the dashboard has emitted over time (AUTHSEC_JWKS_URI, AUTHSEC_INTROSPECTION_ENDPOINT, AUTHSEC_RESOURCE, …); prefer the canonical names above. Pass a prefix argument — FromEnv("MYAPP_") — to change the AUTHSEC_ prefix.

Step 4 — Write the server

A complete, runnable server: MountMCP in front of a minimal MCP handler with two tools, fully protected.

// server.go — go run .
package main

import (
"encoding/json"
"log"
"net/http"

authsec "github.com/authsec-ai/sdk-authsec/packages/go-sdk"
)

func main() {
cfg := authsec.FromEnv() // reads all AUTHSEC_* vars from step 3

// Optional: a hand-curated manifest with suggested scopes. Omit it and the
// SDK enumerates your tools automatically at startup (see the note below).
cfg.ToolInventoryProvider = func() ([]authsec.ManifestTool, error) {
return []authsec.ManifestTool{
{
Name: "add_no",
Description: "Add two numbers",
InputSchema: json.RawMessage(`{"type":"object","properties":{"a":{"type":"number"},"b":{"type":"number"}},"required":["a","b"]}`),
SuggestedScopes: []string{"my_mcp:tools:read"},
},
{
Name: "multiply_no",
Description: "Multiply two numbers",
InputSchema: json.RawMessage(`{"type":"object","properties":{"a":{"type":"number"},"b":{"type":"number"}},"required":["a","b"]}`),
SuggestedScopes: []string{"my_mcp:tools:write"},
},
}, nil
}

mux := http.NewServeMux()
if err := authsec.MountMCP(mux, "/mcp", http.HandlerFunc(mcpHandler), cfg); err != nil {
log.Fatal(err) // returns an error only on an invalid config
}
log.Printf("listening on :8000 — resource_uri=%s", cfg.ResourceURI)
log.Fatal(http.ListenAndServe(":8000", mux))
}

// mcpHandler is a minimal MCP JSON-RPC handler. Real servers use an MCP
// framework; this shows the SDK wraps any plain http.Handler.
func mcpHandler(w http.ResponseWriter, r *http.Request) {
var req struct {
ID json.RawMessage `json:"id"`
Method string `json:"method"`
Params struct {
Name string `json:"name"`
Arguments map[string]float64 `json:"arguments"`
} `json:"params"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
w.Header().Set("Content-Type", "application/json")
reply := func(result any) {
_ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": req.ID, "result": result})
}

switch req.Method {
case "tools/list":
reply(map[string]any{"tools": []map[string]any{
{"name": "add_no", "description": "Add two numbers"},
{"name": "multiply_no", "description": "Multiply two numbers"},
}})
case "tools/call":
a, b := req.Params.Arguments["a"], req.Params.Arguments["b"]
out := a + b
if req.Params.Name == "multiply_no" {
out = a * b
}
reply(map[string]any{"content": []map[string]any{{"type": "text", "text": jsonNumber(out)}}})
default: // initialize, notifications/*, ping, …
reply(map[string]any{"ok": true})
}
}

func jsonNumber(f float64) string { b, _ := json.Marshal(f); return string(b) }

Run it: go run ..

What each piece does:

  • cfg := authsec.FromEnv() builds the Config from your .env. Prefer environment config for anything containerized; for programmatic control you can build the Config{} struct literal directly instead (every field is documented in the Go SDK reference).
  • cfg.ToolInventoryProvider is the escape hatch for manifest publishing. When set, the SDK publishes exactly the tools you return here, with the SuggestedScopes you specify. Omit it and the SDK enumerates your tools automatically at startup with a synthetic MCP handshake (initializenotifications/initializedtools/list) against your un-wrapped handler — the schemas then come straight from your real tool definitions. Set it explicitly when synthetic enumeration doesn't fit (custom auth on initialize, a non-HTTP transport, a static registry) or when you want to attach suggested scopes per tool.
  • ManifestTool fields: Name (the string clients send in tools/call — the join key, so don't rename it lightly), Description (shown to operators and, for high-risk tools, on the consent screen), InputSchema (recorded for the UI; reserved for future per-argument policy), and SuggestedScopes (admin-facing hints — the admin can accept or override them; they do not enforce anything at runtime).
  • MountMCP(mux, "/mcp", handler, cfg) is the whole integration. It runs cfg.Validate(), constructs the runtime, registers two routes on your mux — the wrapped /mcp handler and the RFC 9728 metadata endpoint — and kicks off the background manifest publish. It returns an error only on a bad config (a missing/invalid field), so log.Fatal on it and you're done.

Notice what you did not write: no token parsing, no JWKS fetching, no scope checks, no metadata endpoint, no per-tool guards. Your handler stays a plain MCP handler and only runs for calls that already passed every gate.

Behind another router (chi, gin, gorilla)? MountMCP needs an *http.ServeMux. For other routers, build the runtime yourself and wire both routes:

rt, err := authsec.NewRuntime(cfg)
// ...
router.Handle("/mcp", rt.Wrap(yourHandler))
router.Handle(authsec.BuildResourceMetadataPath(cfg.ResourceURI), rt.ProtectedResourceHandler())

Omitting the metadata route breaks OAuth discovery for clients. See the Go SDK overview.

Step 5 — Run and verify protection from the outside

With the server running (and an ngrok http 8000 tunnel if local):

Check 1 — the PRM document is served (this is how clients discover where to authenticate):

curl https://xxxx.ngrok-free.app/.well-known/oauth-protected-resource/mcp
{
"resource": "https://xxxx.ngrok-free.app/mcp",
"authorization_servers": ["https://mcpauthz.com"],
"scopes_supported": ["my_mcp:read", "my_mcp:tools:read", "..."]
}

scopes_supported is served live from the scope matrix, so scopes you add in the dashboard show up here without a redeploy.

Check 2 — unauthenticated calls are rejected:

curl -i -X POST https://xxxx.ngrok-free.app/mcp \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"tools/list","id":1}'
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata=
"https://xxxx.ngrok-free.app/.well-known/oauth-protected-resource/mcp"

The 401 isn't a dead end — the WWW-Authenticate header tells a well-behaved client exactly where to authenticate.

Check 3 — your tools appeared in the dashboard. Because AUTHSEC_PUBLISH_MANIFEST=true, the SDK published your inventory at startup. Open the Tools tab — every tool is listed with its runtime access state, an auto-assessed risk level, and the access label (scope) that will gate it:

Tools tab — imported tools with risk and access labels

Step 6 — Run the protection check

Back on the Setup tab, click Run protection check. The dashboard probes your live server end-to-end:

Protection check results

CheckWhat it proves
Metadata URLYour PRM endpoint returns a non-empty 200
401 challengeUnauthenticated MCP requests get a proper Bearer challenge
Discovery snapshotYour server's discovery data was captured
Default access policyA default role exists so callers get access on first login
Client registrationAgents have a way to register (pre-registered / DCR / CIMD)
Browser loginUser-login prerequisites are satisfied
tools/list filterTool discovery is ready for scope-filtered listings
tools/call denyThe scope matrix is available for per-tool enforcement

On a fresh application, one check fails — Default access policy. Everything the SDK serves is green; what's missing is a dashboard decision: what does a brand-new caller get? Fix it next.

Step 7 — Set the default access policy (Access tab)

Open the Access tab. The banner: "Default access: closed. New users authenticate but receive no role on first login." Three clicks:

1. Make a role the default — its ⋮ menuMake default role (Viewer is the natural choice):

Access tab — Make default role

2. Grant scopes to that role — Viewer now shows DEFAULT, but grants nothing until it carries a scope. Tick the scopes and click Grant to Viewer:

Access tab — ticking scopes for the Viewer role

3. Save grants — nothing is live until you click Save grants:

Access tab — queued grants, Save grants

Re-run the protection check — all checks pass and launch unlocks:

Protection check passed

Also here — Client registration. Three toggles control how agents may register to call this server: Pre-registration (admin creates the client and copies its client_id), DCR (agents self-register via POST /oauth/register — how Claude Code and Cursor connect), and CIMD (the agent hosts its identity as a JSON file at a public URL). Leave all on unless you want to restrict registration styles.

Step 8 — Map tools to scopes (Tools tab)

Open the Tools tab. Freshly imported tools show denied and "No label assigned" — the fail-closed default from step 4 of the request lifecycle:

Tools tab — tools denied until mapped

Click a tool → its detail panel explains exactly what's wrong ("Denied until mapped: the SDK will fail closed for this tool until an operator maps it to an access label") and shows the access path. Click Map on the right label:

Tool detail — mapping an access label

Map each tool you want callable:

ToolAccess label
add_nomy_mcp:tools:read
multiply_nomy_mcp:tools:write

Anything left unmapped stays denied — safe by default.

Step 9 — Launch

Go to the Overview tab. Runtime state reads Ready and Launch application is active:

Overview — Launch application

Click it. The status flips to Launched — your server is live and protected. Your running server picks up policy changes within ~5 minutes (it polls the scope matrix with a 5-minute cache) — no restart, no redeploy.

Who gets the roles you configured — service accounts, agents, users — is the subject of the M2M and ID-JAG delegation guides.

Step 10 — Full-circle test

Use Run test on the Overview tab, or curl with a real token (from the M2M or delegation guide):

curl -X POST https://xxxx.ngrok-free.app/mcp \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","method":"tools/call","id":2,
"params":{"name":"add_no","arguments":{"a":2,"b":3}}}'
  • Token has my_mcp:tools:readadd_no runs, returns 5
  • Same token calls multiply_no (needs :write) → denied. A JSON-RPC caller with a token gets an in-band error (200, isError:true, _meta.authsec.required_scopes: ["my_mcp:tools:write"]); a plain caller gets 403 insufficient_scope.
  • tools/list is filtered — a caller only sees the tools its scopes allow.

The Monitor tab shows each request as it lands — token subject, tool, allow/deny.


Configuration reference

Every field the SDK reads, and what it does. Set them via FromEnv() (the AUTHSEC_* column) or directly on the Config struct.

Config fieldEnv varPurpose
IssuerAUTHSEC_ISSUERAuthSec base URL. Must equal the token's iss.
AuthorizationServerAUTHSEC_AUTHORIZATION_SERVERAPI origin for policy/manifest URLs. Defaults to Issuer.
JWKSURLAUTHSEC_JWKS_URLJWKS endpoint for JWT signature verification.
IntrospectionURLAUTHSEC_INTROSPECTION_URLRFC 7662 endpoint for the live revocation check.
IntrospectionClientIDAUTHSEC_INTROSPECTION_CLIENT_IDBasic-auth user for introspection + manifest publish (the RS UUID).
IntrospectionClientSecretAUTHSEC_INTROSPECTION_CLIENT_SECRETThe one-time secret from registration. Store in a secret manager.
ResourceURIAUTHSEC_RESOURCE_URIThis server's identity; must equal the token aud. Must be an absolute URL.
ResourceNameAUTHSEC_RESOURCE_NAMEHuman label in the WWW-Authenticate realm and PRM.
ResourceServerIDAUTHSEC_RESOURCE_SERVER_IDRS UUID. When set, the SDK fetches the authoritative scope matrix.
SupportedScopesAUTHSEC_SUPPORTED_SCOPESScopes advertised in PRM as a fallback — the live matrix wins when reachable.
PublishManifestAUTHSEC_PUBLISH_MANIFESTPush the tool inventory to the dashboard at startup. true recommended.
PolicyModeAUTHSEC_POLICY_MODEremote_required (default with an RS ID), remote_with_local_fallback, local_only, open.
ValidationModeAUTHSEC_VALIDATION_MODEjwt_and_introspect (default with both URLs), jwt_only, introspection_only, jwt_or_introspect.
ToolScopesAUTHSEC_TOOL_SCOPES_JSONLocal tool→scope map. Required for remote_with_local_fallback; authoritative for local_only.
ToolScopeSuggestionsAUTHSEC_TOOL_SCOPE_SUGGESTIONS_JSONPer-tool scope hints sent in the manifest (admin-facing; no runtime effect).
ToolInventoryProviderfunc() ([]ManifestTool, error) — hand-curated manifest instead of synthetic enumeration.
ScopeMatrixTTLHow long fetched policy is cached. Default 5 min.
LoggerYour *slog.Logger. Every validate / authorize / deny emits a structured line.
HTTPClientYour *http.Client (default 10 s timeout).

PolicyMode decides where per-tool policy comes from and what happens when AuthSec is unreachable:

  • remote_required — fetch from AuthSec; deny (503) if unreachable. The production default.
  • remote_with_local_fallback — fetch from AuthSec, fall back to ToolScopes on failure (so ToolScopes is required).
  • local_only — ignore AuthSec; use ToolScopes only. Good for air-gapped tests.
  • open — no per-tool policy; any valid token may call any tool. An "observe" mode for finishing your scope mapping — the SDK still attaches the validated Principal to the context, it just doesn't gate.

ValidationMode decides how a token is checked (see step 2 of the request lifecycle). jwt_and_introspect is strict: a JWT must pass local verification and introspection; introspection cannot rescue a JWT that failed locally. jwt_or_introspect is the permissive legacy mode kept for migration.


Troubleshooting

SymptomCauseFix
MountMCP returns "invalid config" at startupA required field is empty or ResourceURI has no scheme/hostcfg.Validate() names the field — check the reference above
Startup fails with policy fetch failedremote_required + the application isn't launched yetFinish launch, or use remote_with_local_fallback during onboarding
Every tool call returns 403 / in-band deny, tools show deniedTools aren't mapped, or no default access policySteps 7–8: default role + grants, then map each tool
A tool stays denied after launch"No label assigned" — unmapped tools fail closedStep 8 — open the tool, Map an access label
401 even with a fresh tokenAUTHSEC_RESOURCE_URI doesn't exactly match the URL the client calls (scheme/host/path)Make them identical; re-check after an ngrok URL change
Tools don't appear in the Tools tabAUTHSEC_PUBLISH_MANIFEST not true, or the startup PUT failedCheck boot logs; verify introspection credentials
Scope change not taking effectWithin the 5-minute cache windowWait ~5 min, or restart; confirm the server can reach mcpauthz.com
Everything breaks when AuthSec is briefly unreachableremote_required failing closed — by designDev only: relax AUTHSEC_POLICY_MODE; keep fail-closed in production
Metadata path returns 404Wrong PRM path for a path-based resourceUse BuildResourceMetadataPath(cfg.ResourceURI); /mcp/.well-known/oauth-protected-resource/mcp
Send Authorization: Bearer garbage, get 200Your middleware isn't actually runningConfirm MountMCP wraps the same route the client hits

Implementation reference

The files that implement this behavior:

  • Token validationvalidator.go. In jwt_and_introspect, a JWT-shaped token is (1) RSA-signature-verified against AuthSec's JWKS with iss/exp checks, then (2) introspected live so revoked tokens die immediately; introspection is authoritative for active/scopes/aud. Opaque tokens skip straight to introspection.
  • Scope matrixscope_matrix_client.go. The tool→scope mapping from step 8 is fetched from AuthSec and cached for 5 minutes — that's the "propagates in ~5 minutes" guarantee.
  • Manifestmanifest_publisher.go. At startup the SDK builds the inventory (synthetic tools/list or your ToolInventoryProvider) and PUTs it to AuthSec.
  • In-band denialsmcp_inband.go. The MCP-client-aware error behavior described above.