smeldr.dev/oauth v0.1.0 · smeldr.dev/mcp v1.11.0
Claude Desktop connects to an MCP server by spawning a local process. That works well for a developer's own machine, but it does not work for ChatGPT, Claude.ai, or any AI assistant that runs in a browser or on someone else's server. Remote MCP connections require a standard auth layer, and the MCP specification is specific about which one: OAuth 2.1 with mandatory PKCE.
smeldr.dev/oauth ships a full OAuth 2.1 authorization server. smeldr.dev/mcp adds the protected-resource discovery endpoint the spec requires. Both wire into an existing Smeldr app in four lines.
What we chose not to build: a client registry
Most OAuth servers maintain a table of registered clients — you register your app, receive a client_id and client_secret, and the server validates against those rows. That works fine for traditional app integrations, but AI assistants publish their own metadata at known HTTPS URLs. ChatGPT registers at https://chatgpt.com/.well-known/openid-configuration (or similar). The MCP specification formalizes this as CIMD — Client ID Metadata Documents.
Instead of a registry, smeldr.dev/oauth fetches the client's own URL at authorization time:
GET <client_id URL> → { "client_name": "ChatGPT", "redirect_uris": [...] }
No oauth_clients table. No registration step. Any well-known AI assistant that publishes a CIMD document is automatically a valid client. If you want to restrict access, block at the network level or check the issuer hostname in VerifyBearer.
Two spec details that matter: PKCE and offline_access
PKCE is mandatory in OAuth 2.1 — there is no authorization code flow without it. The client generates a random code_verifier, hashes it to a code_challenge, sends the challenge in the authorization request, and proves it holds the verifier at token exchange. This prevents authorization code interception attacks and is why OAuth 2.1 drops the implicit flow entirely.
Refresh tokens are optional per spec but practically required for AI assistants. Without them, an assistant loses access when the access token expires — typically after one hour — and the user has to re-authorize. The offline_access scope triggers issuance of a refresh token alongside the access token.
Both are handled automatically. PKCE is verified on every code exchange. Refresh tokens are issued when the client requests the offline_access scope.
Revocation is also supported: POST /oauth/revoke per RFC 7009. The response is always 200 OK per spec, whether or not the token existed — the client cannot infer validity from the response.
Wiring
smeldr.dev/oauth keeps its own store (a SQLite file or any oauth.Store implementation). It reuses your existing Smeldr bearer tokens as the credential on the authorization form — the user pastes their Smeldr token to approve access, and no separate user table is needed.
import (
"smeldr.dev/core"
"smeldr.dev/mcp"
"smeldr.dev/oauth"
)
store, err := oauth.NewSQLiteStore("./oauth.db")
if err != nil {
log.Fatal(err)
}
oauthSrv := oauth.New(oauth.Config{
Issuer: "https://cms.example.com",
VerifyBearer: func(token string) bool {
_, ok := smeldr.VerifyTokenString(token, app.Secret(), app.TokenStore())
return ok
},
}, store)
mcpSrv := mcp.New(app, mcp.WithOAuth(oauthSrv))
mcpSrv.Register(app)
Register mounts everything in one call:
GET /.well-known/oauth-authorization-server RFC 8414 discovery (served by oauth)
GET /.well-known/oauth-protected-resource RFC 9728 discovery (served by mcp)
GET /oauth/authorize authorization form
POST /oauth/authorize bearer token validation + code issue
POST /oauth/token code exchange + token refresh
POST /oauth/revoke token revocation (RFC 7009)
GET /mcp MCP SSE stream (now requires Bearer)
POST /mcp/message MCP JSON-RPC
When OAuth is enabled, the SSE endpoint responds 401 to unauthenticated clients and includes a WWW-Authenticate: Bearer resource_metadata="..." header. AI clients follow that header to the protected-resource document, discover the authorization server, and start the OAuth flow.
Status: ChatGPT works, Claude.ai web requires T38
ChatGPT Plus connects correctly. The full flow - discovery, PKCE authorization code, token exchange, refresh - works against a live Smeldr site. Tested with ngrok in development, then against a deployed instance.
Claude.ai web uses the streamable HTTP transport introduced in the MCP 2025-11-25 specification. Smeldr's MCP server currently serves SSE only. The initialize handshake Claude.ai web sends (POST /mcp) is not handled as streamable HTTP, so the connection cannot complete. This will be resolved when streamable HTTP transport ships (T38). Claude Desktop works today via a local stdio connection.
What the module does not include
smeldr.dev/oauth is an authorization server, not an identity provider. It does not manage users, issue JWTs, or integrate with LDAP. The only credential it understands is a Smeldr bearer token — the same token you'd use to call /_health or /_stats. Role enforcement (which MCP tools a connected client may call) is handled by the MCP server layer, not the OAuth layer.