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 Unauthorizedwith aWWW-Authenticate: Bearer resource_metadata="…"header pointing the client at the login flow, or403for a scope failure. - A token is present and the body is JSON-RPC → the denial is returned
in-band: HTTP
200with a JSON-RPC error (or atools/callresult withisError: true) carrying a_meta.authsec/data.authsecobject (error,status,required_scopes,granted_scopes,tool) and a plain-English message. This is deliberate: an MCP client that gets a raw401mid-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-32003for a403and-32001for a401. - 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 (
PolicyModeRemoteRequiredand 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;MountMCPregisters it. - Manifest publishing (when
PublishManifestis true). The SDK performs a synthetictools/listagainst your un-wrapped handler, thenPUTs 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:
One dependency (
go get github.com/authsec-ai/sdk-authsec/packages/go-sdkgithub.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 8000→https://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:

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

2a. Define the 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:

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:

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:

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

🔑 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 — seePolicyMode.)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 bothJWKS_URLandINTROSPECTION_URLare set.
FromEnv()reads, it does not validate. It only parses theAUTHSEC_*variables into aConfig.MountMCP/NewRuntimeruncfg.Validate()and fail loudly with the exact missing field.FromEnvalso 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 theAUTHSEC_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 theConfigfrom your.env. Prefer environment config for anything containerized; for programmatic control you can build theConfig{}struct literal directly instead (every field is documented in the Go SDK reference).cfg.ToolInventoryProvideris the escape hatch for manifest publishing. When set, the SDK publishes exactly the tools you return here, with theSuggestedScopesyou specify. Omit it and the SDK enumerates your tools automatically at startup with a synthetic MCP handshake (initialize→notifications/initialized→tools/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 oninitialize, a non-HTTP transport, a static registry) or when you want to attach suggested scopes per tool.ManifestToolfields:Name(the string clients send intools/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), andSuggestedScopes(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 runscfg.Validate(), constructs the runtime, registers two routes on your mux — the wrapped/mcphandler 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), solog.Fatalon 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)?
MountMCPneeds 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:

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

| Check | What it proves |
|---|---|
| Metadata URL | Your PRM endpoint returns a non-empty 200 |
| 401 challenge | Unauthenticated MCP requests get a proper Bearer challenge |
| Discovery snapshot | Your server's discovery data was captured |
| Default access policy | A default role exists so callers get access on first login |
| Client registration | Agents have a way to register (pre-registered / DCR / CIMD) |
| Browser login | User-login prerequisites are satisfied |
| tools/list filter | Tool discovery is ready for scope-filtered listings |
| tools/call deny | The 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 ⋮ menu → Make default role (Viewer is the natural choice):

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:

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

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

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 viaPOST /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:

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:

Map each tool you want callable:
| Tool | Access label |
|---|---|
add_no | my_mcp:tools:read |
multiply_no | my_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:

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:read→add_noruns, returns5✅ - 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 gets403 insufficient_scope. tools/listis 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 field | Env var | Purpose |
|---|---|---|
Issuer | AUTHSEC_ISSUER | AuthSec base URL. Must equal the token's iss. |
AuthorizationServer | AUTHSEC_AUTHORIZATION_SERVER | API origin for policy/manifest URLs. Defaults to Issuer. |
JWKSURL | AUTHSEC_JWKS_URL | JWKS endpoint for JWT signature verification. |
IntrospectionURL | AUTHSEC_INTROSPECTION_URL | RFC 7662 endpoint for the live revocation check. |
IntrospectionClientID | AUTHSEC_INTROSPECTION_CLIENT_ID | Basic-auth user for introspection + manifest publish (the RS UUID). |
IntrospectionClientSecret | AUTHSEC_INTROSPECTION_CLIENT_SECRET | The one-time secret from registration. Store in a secret manager. |
ResourceURI | AUTHSEC_RESOURCE_URI | This server's identity; must equal the token aud. Must be an absolute URL. |
ResourceName | AUTHSEC_RESOURCE_NAME | Human label in the WWW-Authenticate realm and PRM. |
ResourceServerID | AUTHSEC_RESOURCE_SERVER_ID | RS UUID. When set, the SDK fetches the authoritative scope matrix. |
SupportedScopes | AUTHSEC_SUPPORTED_SCOPES | Scopes advertised in PRM as a fallback — the live matrix wins when reachable. |
PublishManifest | AUTHSEC_PUBLISH_MANIFEST | Push the tool inventory to the dashboard at startup. true recommended. |
PolicyMode | AUTHSEC_POLICY_MODE | remote_required (default with an RS ID), remote_with_local_fallback, local_only, open. |
ValidationMode | AUTHSEC_VALIDATION_MODE | jwt_and_introspect (default with both URLs), jwt_only, introspection_only, jwt_or_introspect. |
ToolScopes | AUTHSEC_TOOL_SCOPES_JSON | Local tool→scope map. Required for remote_with_local_fallback; authoritative for local_only. |
ToolScopeSuggestions | AUTHSEC_TOOL_SCOPE_SUGGESTIONS_JSON | Per-tool scope hints sent in the manifest (admin-facing; no runtime effect). |
ToolInventoryProvider | — | func() ([]ManifestTool, error) — hand-curated manifest instead of synthetic enumeration. |
ScopeMatrixTTL | — | How long fetched policy is cached. Default 5 min. |
Logger | — | Your *slog.Logger. Every validate / authorize / deny emits a structured line. |
HTTPClient | — | Your *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 toToolScopeson failure (soToolScopesis required).local_only— ignore AuthSec; useToolScopesonly. 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 validatedPrincipalto 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
| Symptom | Cause | Fix |
|---|---|---|
MountMCP returns "invalid config" at startup | A required field is empty or ResourceURI has no scheme/host | cfg.Validate() names the field — check the reference above |
Startup fails with policy fetch failed | remote_required + the application isn't launched yet | Finish launch, or use remote_with_local_fallback during onboarding |
Every tool call returns 403 / in-band deny, tools show denied | Tools aren't mapped, or no default access policy | Steps 7–8: default role + grants, then map each tool |
| A tool stays denied after launch | "No label assigned" — unmapped tools fail closed | Step 8 — open the tool, Map an access label |
401 even with a fresh token | AUTHSEC_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 tab | AUTHSEC_PUBLISH_MANIFEST not true, or the startup PUT failed | Check boot logs; verify introspection credentials |
| Scope change not taking effect | Within the 5-minute cache window | Wait ~5 min, or restart; confirm the server can reach mcpauthz.com |
| Everything breaks when AuthSec is briefly unreachable | remote_required failing closed — by design | Dev only: relax AUTHSEC_POLICY_MODE; keep fail-closed in production |
| Metadata path returns 404 | Wrong PRM path for a path-based resource | Use BuildResourceMetadataPath(cfg.ResourceURI); /mcp → /.well-known/oauth-protected-resource/mcp |
Send Authorization: Bearer garbage, get 200 | Your middleware isn't actually running | Confirm MountMCP wraps the same route the client hits |
Implementation reference
The files that implement this behavior:
- Token validation —
validator.go. Injwt_and_introspect, a JWT-shaped token is (1) RSA-signature-verified against AuthSec's JWKS withiss/expchecks, then (2) introspected live so revoked tokens die immediately; introspection is authoritative foractive/scopes/aud. Opaque tokens skip straight to introspection. - Scope matrix —
scope_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. - Manifest —
manifest_publisher.go. At startup the SDK builds the inventory (synthetictools/listor yourToolInventoryProvider) andPUTs it to AuthSec. - In-band denials —
mcp_inband.go. The MCP-client-aware error behavior described above.
Related
- Go SDK overview & configuration reference — every
Configfield and env var - Machine-to-machine auth — get a token to call this server
- ID-JAG delegation — call it on behalf of a user
- What is a tool manifest? — the inventory the SDK publishes
- How AuthSec protects your MCP server — the conceptual model