From latestaiagents
Implement OAuth 2.1 + PKCE authentication for remote MCP servers, including dynamic client registration, token refresh, and scope design. Covers the 2025 MCP auth spec that Claude Desktop, Claude Code, and ChatGPT use. Use this skill when building a remote MCP server that needs per-user auth, debugging OAuth flows for MCP, or migrating a bearer-token MCP server to OAuth. Activate when: MCP OAuth, remote MCP auth, MCP authorization, PKCE, dynamic client registration, MCP 401.
npx claudepluginhub latestaiagents/agent-skills --plugin skills-authoringThis skill uses the workspace's default tool permissions.
**Remote MCP servers use OAuth 2.1 + PKCE with Dynamic Client Registration. Any spec-compliant MCP client — Claude Desktop, Claude Code, Cursor, ChatGPT Apps — can authenticate users without hardcoded client IDs.**
Implements MCP server authentication via OAuth dynamic client registration (RFC 7591/8414), PKCE, token flows, plus bearer/API keys with shared or per-agent credentials. For admin UIs connecting to providers like Linear, Sentry, Render.
Implements MCP server authentication using OAuth dynamic client registration (RFC 7591/8414), PKCE, bearer tokens, and API keys for admin UIs. Supports per-agent credentials, metadata discovery, token exchange, and tool sync for providers like Linear, Sentry.
Provides best practices for production MCP servers with TypeScript SDK: spec 2025-11-25, v1.28+/v2, Streamable HTTP/stdio transports, tool design, errors, security, performance, extensions, MCP Apps, Registry.
Share bugs, ideas, or general feedback.
Remote MCP servers use OAuth 2.1 + PKCE with Dynamic Client Registration. Any spec-compliant MCP client — Claude Desktop, Claude Code, Cursor, ChatGPT Apps — can authenticate users without hardcoded client IDs.
401 with WWW-Authenticate pointing to the authorization server metadata URL/.well-known/oauth-authorization-server → gets authorization_endpoint, token_endpoint, registration_endpointregistration_endpoint (RFC 7591) → receives a client_id dynamicallyauthorization_endpoint with PKCE challenge → user approvestoken_endpoint → receives access + refresh tokensAuthorization: Bearer <token>No pre-shared client ID. No manual app registration. The user just clicks "Allow" in a browser.
// GET /.well-known/oauth-authorization-server
app.get("/.well-known/oauth-authorization-server", (req, res) => {
res.json({
issuer: "https://mcp.example.com",
authorization_endpoint: "https://mcp.example.com/oauth/authorize",
token_endpoint: "https://mcp.example.com/oauth/token",
registration_endpoint: "https://mcp.example.com/oauth/register",
scopes_supported: ["read:issues", "write:issues"],
response_types_supported: ["code"],
grant_types_supported: ["authorization_code", "refresh_token"],
code_challenge_methods_supported: ["S256"],
token_endpoint_auth_methods_supported: ["none"], // public clients
});
});
app.use("/mcp", (req, res, next) => {
const token = req.headers.authorization?.replace("Bearer ", "");
if (!token || !verifyJwt(token)) {
res.setHeader(
"WWW-Authenticate",
`Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource"`,
);
return res.status(401).json({ error: "unauthorized" });
}
req.user = decodeJwt(token);
next();
});
app.post("/oauth/register", async (req, res) => {
const { redirect_uris, client_name } = req.body;
const clientId = `mcp_${crypto.randomUUID()}`;
await db.clients.insert({ clientId, redirectUris: redirect_uris, name: client_name });
res.json({
client_id: clientId,
redirect_uris,
token_endpoint_auth_method: "none",
grant_types: ["authorization_code", "refresh_token"],
});
});
app.get("/oauth/authorize", async (req, res) => {
const { client_id, redirect_uri, code_challenge, code_challenge_method, state, scope } = req.query;
if (code_challenge_method !== "S256") return res.status(400).send("S256 required");
// render consent UI; on approval:
const code = crypto.randomUUID();
await db.codes.insert({ code, clientId: client_id, userId: req.session.userId, codeChallenge: code_challenge, scope });
res.redirect(`${redirect_uri}?code=${code}&state=${state}`);
});
app.post("/oauth/token", async (req, res) => {
const { grant_type, code, code_verifier, refresh_token } = req.body;
if (grant_type === "authorization_code") {
const row = await db.codes.findAndDelete({ code });
const hash = crypto.createHash("sha256").update(code_verifier).digest("base64url");
if (hash !== row.codeChallenge) return res.status(400).json({ error: "invalid_grant" });
return res.json(issueTokens(row.userId, row.scope));
}
if (grant_type === "refresh_token") {
const userId = verifyRefresh(refresh_token);
return res.json(issueTokens(userId));
}
});
Group by resource + action, not by tool:
read:issues → list/get issues
write:issues → create/update/close issues
read:repos → list/get repos
admin:webhooks → create/delete webhooks
Don't use full_access — clients can't reason about what they're granting.
Map scopes to MCP tools in your handler:
server.tool("create_issue", "...", schema, async (args, { authInfo }) => {
if (!authInfo.scopes.includes("write:issues")) {
throw new Error("Missing scope: write:issues. Reconnect with this scope enabled.");
}
// ...
});
Claude Desktop, Claude Code, and the Claude Agent SDK handle all of this automatically when you specify an url server without headers:
{
"mcpServers": {
"linear": { "url": "https://mcp.linear.app/sse" }
}
}
WWW-Authenticate header is present on 401/oauth/register must accept application/json and not require authinvalid_scopeplain and missing challenges/.well-known/oauth-protected-resource metadata doc per MCP 2025 spec