Bug
verifyAccessToken in src/core/oauth-provider.ts throws plain Error instances when a bearer token is invalid or expired. The MCP SDK's requireBearerAuth middleware (@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js) classifies thrown errors:
InvalidTokenError → HTTP 401 + WWW-Authenticate (per RFC 6749 / RFC 6750)
InsufficientScopeError → HTTP 403
ServerError → HTTP 500
- Anything else (plain
Error) → HTTP 500 with server_error
Because gbrain throws plain Error, bearer-token validation failures return HTTP 500 server_error instead of the canonical RFC 6750 401 invalid_token.
Reproducer
# Server v0.33.0 running (postgres engine, OAuth clients registered)
curl -X POST https://your-gbrain/mcp \
-H "Content-Type: application/json" \
-H "Authorization: Bearer invalid_token" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
# Actual:
# {"error":"server_error","error_description":"Internal Server Error"}
# HTTP/2 500
# Expected (per RFC 6750 §3.1):
# {"error":"invalid_token","error_description":"Invalid token"}
# HTTP/2 401
# WWW-Authenticate: Bearer error="invalid_token", error_description="Invalid token"
Verified on both local (macOS) and remote (Railway) v0.33.0 deployments.
Root cause
src/core/oauth-provider.ts:
- Line ~423:
throw new Error('Token expired');
- Line ~458:
throw new Error('Invalid token');
Both inside verifyAccessToken. SDK bearerAuth.js:53-69 catch block routes plain Error to the else branch:
else {
const serverError = new ServerError('Internal Server Error');
res.status(500).json(serverError.toResponseObject());
}
Suggested fix
import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js';
+import { InvalidTokenError } from '@modelcontextprotocol/sdk/server/auth/errors.js';
import { hashToken, generateToken, isUndefinedColumnError } from './utils.ts';
...
const expiresAt = coerceTimestamp(row.expires_at);
if (expiresAt === undefined || expiresAt < now) {
- throw new Error('Token expired');
+ throw new InvalidTokenError('Token expired');
}
...
- throw new Error('Invalid token');
+ throw new InvalidTokenError('Invalid token');
Caveat: I tried this patch locally. It typechecks (bun run typecheck passes) and works perfectly when running via bun run src/cli.ts serve --http. But bun build --compile then fails at runtime — the binary exits silently. My hypothesis is that the deep import path @modelcontextprotocol/sdk/server/auth/errors.js doesn't bundle correctly with bun build --compile (the SDK's exports map uses a ./* glob fallback for this path rather than an explicit entry). The interpreted path is fine.
Possible workarounds for the build issue:
- Re-export
InvalidTokenError from a path that IS explicit in the SDK's exports map.
- Define a local class that extends the SDK's
OAuthError with errorCode = 'invalid_token' so instanceof InvalidTokenError still matches via the prototype chain (or instanceof OAuthError in the SDK side).
- Lazy
await import() inside verifyAccessToken.
Impact
- Security posture: clients receive misleading 500
server_error on invalid tokens (suggests server bug) instead of clean 401 invalid_token (which clients should auto-refresh on).
- Spec compliance: RFC 6750 §3.1 requires 401 +
WWW-Authenticate for invalid bearer tokens.
- Client behavior: well-behaved OAuth clients (claude.ai web app's token-refresh logic) interpret 500 as transient and retry instead of refreshing the token.
Doctrine references
SECURITY.md — OAuth 2.1 compliance section
docs/mcp/DEPLOY.md — DEPLOY troubleshooting cites "missing_auth" and "invalid_token" as canonical client-facing error codes
Notes
- Same pattern may apply to refresh-token validation throws (lines ~367, ~374) but those go through
mcpAuthRouter /token endpoint (RFC 6749 §5.2 → HTTP 400 invalid_grant), not bearerAuth. Out of scope for this fix.
- Found during a forensic audit of a Railway-deployed
gbrain serve --http instance integrated with claude.ai web — happy to share the audit notes if useful.
Bug
verifyAccessTokeninsrc/core/oauth-provider.tsthrows plainErrorinstances when a bearer token is invalid or expired. The MCP SDK'srequireBearerAuthmiddleware (@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js) classifies thrown errors:InvalidTokenError→ HTTP 401 + WWW-Authenticate (per RFC 6749 / RFC 6750)InsufficientScopeError→ HTTP 403ServerError→ HTTP 500Error) → HTTP 500 withserver_errorBecause gbrain throws plain
Error, bearer-token validation failures return HTTP 500server_errorinstead of the canonical RFC 6750 401invalid_token.Reproducer
Verified on both local (macOS) and remote (Railway) v0.33.0 deployments.
Root cause
src/core/oauth-provider.ts:throw new Error('Token expired');throw new Error('Invalid token');Both inside
verifyAccessToken. SDKbearerAuth.js:53-69catch block routes plainErrorto theelsebranch:Suggested fix
import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js'; +import { InvalidTokenError } from '@modelcontextprotocol/sdk/server/auth/errors.js'; import { hashToken, generateToken, isUndefinedColumnError } from './utils.ts'; ... const expiresAt = coerceTimestamp(row.expires_at); if (expiresAt === undefined || expiresAt < now) { - throw new Error('Token expired'); + throw new InvalidTokenError('Token expired'); } ... - throw new Error('Invalid token'); + throw new InvalidTokenError('Invalid token');Caveat: I tried this patch locally. It typechecks (
bun run typecheckpasses) and works perfectly when running viabun run src/cli.ts serve --http. Butbun build --compilethen fails at runtime — the binary exits silently. My hypothesis is that the deep import path@modelcontextprotocol/sdk/server/auth/errors.jsdoesn't bundle correctly withbun build --compile(the SDK'sexportsmap uses a./*glob fallback for this path rather than an explicit entry). The interpreted path is fine.Possible workarounds for the build issue:
InvalidTokenErrorfrom a path that IS explicit in the SDK'sexportsmap.OAuthErrorwitherrorCode = 'invalid_token'soinstanceof InvalidTokenErrorstill matches via the prototype chain (orinstanceof OAuthErrorin the SDK side).await import()insideverifyAccessToken.Impact
server_erroron invalid tokens (suggests server bug) instead of clean 401invalid_token(which clients should auto-refresh on).WWW-Authenticatefor invalid bearer tokens.Doctrine references
SECURITY.md— OAuth 2.1 compliance sectiondocs/mcp/DEPLOY.md— DEPLOY troubleshooting cites "missing_auth" and "invalid_token" as canonical client-facing error codesNotes
mcpAuthRouter/tokenendpoint (RFC 6749 §5.2 → HTTP 400invalid_grant), not bearerAuth. Out of scope for this fix.gbrain serve --httpinstance integrated with claude.ai web — happy to share the audit notes if useful.