built-in-authorization-server
An MCP authorization server implementing OAuth2/OIDC flows, supporting dynamic client registration, PKCE, consent screens, and client ID metadata documents for MCP authorization.
README
built-in-authorization-server
Install & run
npm install
PORT=3005 MCP_FAKE_AUTH=1 MCP_FAKE_IDP=1 npm start
Server URL is http://localhost:3005; apiPrefix is auth.
Two modes
REAL mode
Provide real provider credentials in the environment and the real GitHub provider is wired exactly as documented:
GITHUB_CLIENT_ID=xxx GITHUB_CLIENT_SECRET=yyy JWT_SECRET=<32+ chars> PORT=3014 npm start
The interactive browser leg (/auth/authorize → GitHub → /auth/callback)
requires a live GitHub App and is only exercised in this mode.
FAKE mode (offline, MCP_FAKE_AUTH=1)
When MCP_FAKE_AUTH=1 is set and no real credentials are present, dummy
clientId/clientSecret are supplied so the module constructs without ever
contacting an IdP. Every offline-reachable feature works:
- Discovery:
/.well-known/oauth-authorization-server,/.well-known/oauth-protected-resource - Dynamic Client Registration (RFC 7591):
POST /auth/register(deprecated in MCP revision2026-07-28, still fully supported here) - PKCE required and advertised (
code_challenge_methods_supported: ["S256"]) - JWT validation of locally-signed tokens by the
/mcpmiddleware - Guarded MCP calls with a locally-minted JWT
Mint a local token (signed with the same jwtSecret, so validateToken accepts
it without any IdP call):
TOKEN=$(npx ts-node scripts/mint-jwt.ts)
bunx @modelcontextprotocol/inspector --cli http://localhost:3014/mcp \
--transport http --header "Authorization: Bearer $TOKEN" --method tools/list
With MCP_FAKE_AUTH=1 alone the authorize → callback interactive leg still
needs the external IdP and is not runnable. Add MCP_FAKE_IDP=1 (below) to
get an offline stand-in for it.
What was verified (FAKE mode)
- Discovery JSON shape matches the doc.
POST /auth/registerreturns a registered client (client_id).- PKCE methods advertised.
/mcpaccepts a valid locally-minted JWT (whoami→Hello, Ada Lovelace!), rejects missing / malformed / wrong-secret tokens with401.disableEndpointsdisables a discovery route (→404) while keeping the other.@McpUser()projectsreq.user.
Tier 4 walkthrough: consent screen and Client ID Metadata Documents
Three opt-in environment flags, all default off. With none of them set this example behaves exactly as it did before they existed — same routes, same metadata document, same GitHub provider, same log output.
| Flag | Effect |
|---|---|
MCP_CONSENT=1 |
Turns on the interactive consent screen (consent: { enabled: true }). |
MCP_CIMD=1 |
Accepts URL-shaped client_ids, and hosts GET /client-metadata.json + GET /demo-callback so the client side of the demo runs locally too. Implies consent — McpAuthModule forces it on. |
MCP_FAKE_IDP=1 |
⚠️ Replaces GitHub with a local stub that authenticates everyone as "Ada Lovelace", with no network call. Implies MCP_FAKE_AUTH=1. |
FAKE-mode shortcuts, stated plainly
Everything below except MCP_FAKE_IDP=1 is real production behaviour. Two things
are shortcuts and neither is safe outside a demo:
MCP_FAKE_IDP=1— there is no authentication. A real deployment sends the user to GitHub/Google/Keycloak here. It exists only so the browser leg (and therefore the consent screen) can be walked through offline.allowInsecureClientIdScheme: true, whichMCP_CIMD=1sets — this acceptshttp://client_idURLs and disables the SSRF guard, which is the only reasonhttp://localhost:3014/client-metadata.jsonworks as aclient_id. With it on, an unauthenticated caller can make this server fetch e.g.https://169.254.169.254/...(the cloud instance-metadata endpoint). In production the document must live on a publichttpsorigin and this option must stayfalse.
A. Consent screen only
PORT=3014 MCP_FAKE_IDP=1 MCP_CONSENT=1 npm start
Register a client and start an authorization request in a browser:
CLIENT_ID=$(curl -s -X POST http://localhost:3014/auth/register \
-H 'content-type: application/json' \
-d '{"client_name":"DCR Consent Demo","redirect_uris":["http://127.0.0.1:33418/callback"]}' \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["client_id"])')
echo "http://localhost:3014/auth/authorize?response_type=code&client_id=$CLIENT_ID&redirect_uri=http%3A%2F%2F127.0.0.1%3A33418%2Fcallback&code_challenge=WMlOi-4SP-ouuMs3uOb7jhkkMKK5SSJ9Z8Eagvs1wmk&code_challenge_method=S256&state=demo-state"
Open that URL. The fake IdP bounces straight back to /auth/callback, which now
answers with the consent screen instead of redirecting. It shows:
- the client name, and who you are signed in as ("Ada Lovelace"),
127.0.0.1— the redirect URI hostname, as its own prominent field (draft/basic/authorization/security-considerationsmakes displaying this a MUST for a CIMD-capable server),- an orange loopback warning, because the code will be delivered to a port on this machine that any local process could have bound (a SHOULD),
- the scopes that will actually be granted — already narrowed, so you see what you are approving rather than what was requested.
Click Approve and the browser lands on
http://127.0.0.1:33418/callback?code=…&state=demo-state&iss=http://localhost:3014.
Nothing is listening on 33418, so the browser shows a connection error — the
code in the address bar is the point. Click Deny instead and you get
?error=access_denied&… on the same URI (RFC 6749 §4.1.2.1).
The code_challenge above is the S256 hash of the verifier
sSQKjtmhf3TsxI14yDaGqZpyHg7l1hzoYGMHvRtknJQ, so you can redeem the code:
curl -s -X POST http://localhost:3014/auth/token \
-H 'content-type: application/x-www-form-urlencoded' \
--data-urlencode grant_type=authorization_code \
--data-urlencode "code=<the code from the address bar>" \
--data-urlencode code_verifier=sSQKjtmhf3TsxI14yDaGqZpyHg7l1hzoYGMHvRtknJQ \
--data-urlencode redirect_uri=http://127.0.0.1:33418/callback \
--data-urlencode "client_id=$CLIENT_ID"
Approving is remembered for the same (user, client, scope) triple, so a second
authorization request goes straight through with no screen. Start the server with
MCP_CONSENT=1 and consent.rememberForMs: 0 in main.ts to prompt every time.
B. Client ID Metadata Documents
PORT=3014 MCP_FAKE_IDP=1 MCP_CIMD=1 npm start
The server now advertises support, and hosts a document as if it were the client:
curl -s http://localhost:3014/.well-known/oauth-authorization-server \
| grep -o '"client_id_metadata_document_supported":true'
curl -s http://localhost:3014/client-metadata.json
{
"client_id": "http://localhost:3014/client-metadata.json", // MUST match this URL exactly
"client_name": "CIMD Demo Client",
"client_uri": "http://localhost:3014",
"redirect_uris": [
"http://localhost:3014/demo-callback", // a viewable stand-in
"http://127.0.0.1:33418/callback" // what a real MCP client uses
],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none" // CIMD clients are public
}
There is no registration step. The URL is the client_id. Open:
http://localhost:3014/auth/authorize?response_type=code&client_id=http%3A%2F%2Flocalhost%3A3014%2Fclient-metadata.json&redirect_uri=http%3A%2F%2Flocalhost%3A3014%2Fdemo-callback&code_challenge=WMlOi-4SP-ouuMs3uOb7jhkkMKK5SSJ9Z8Eagvs1wmk&code_challenge_method=S256&state=demo-state&scope=offline_access
The server fetches the document, validates it, and shows the consent screen — with one extra sentence in the loopback warning, because a name that came from a document anyone may reference deserves less trust than a registered one:
Authorization code will be sent to
localhost
http://localhost:3014/demo-callback
This client receives the authorization code on this computer.
localhost is a loopback address, so any program running locally could have
opened that port and be impersonating CIMD Demo Client, whose name and logo
come from a document that anyone may reference. Approve only if you just
started this application yourself.
Client identifier http://localhost:3014/client-metadata.json
Client website http://localhost:3014
Requested access offline_access
Click Approve and this time the browser lands on a page that shows you the
code, state and iss, because /demo-callback is served by this example.
Redeem it with the same URL as client_id (and no secret — it is a public
client):
curl -s -X POST http://localhost:3014/auth/token \
-H 'content-type: application/x-www-form-urlencoded' \
--data-urlencode grant_type=authorization_code \
--data-urlencode "code=<the code>" \
--data-urlencode code_verifier=sSQKjtmhf3TsxI14yDaGqZpyHg7l1hzoYGMHvRtknJQ \
--data-urlencode redirect_uri=http://localhost:3014/demo-callback \
--data-urlencode client_id=http://localhost:3014/client-metadata.json
{ "access_token": "eyJhbGciOiJIUzI1NiIs…", "token_type": "bearer", "expires_in": 86400, "refresh_token": "…" }
That token works against /mcp like any other:
bunx @modelcontextprotocol/inspector --cli http://localhost:3014/mcp \
--transport http --header "Authorization: Bearer <access_token>" --method tools/list
Redemption used the document snapshot taken at /authorize, not a second
fetch — so the grant is pinned to exactly the metadata the user saw.
C. Rejections worth seeing
Every one of these is an HTTP 400 with a message naming the reason ("if the
authorization server fails to retrieve the client metadata document, it SHOULD
abort the authorization request"). Run with MCP_CIMD=1 and vary client_id:
Q='response_type=code&redirect_uri=http%3A%2F%2Flocalhost%3A3014%2Fdemo-callback&code_challenge=WMlOi-4SP-ouuMs3uOb7jhkkMKK5SSJ9Z8Eagvs1wmk&code_challenge_method=S256&state=s'
# nothing hosted there
curl -s "http://localhost:3014/auth/authorize?$Q&client_id=http%3A%2F%2Flocalhost%3A3014%2Fnope.json"
# → client metadata document request returned HTTP 404
# no path component
curl -s "http://localhost:3014/auth/authorize?$Q&client_id=http%3A%2F%2Flocalhost%3A3014"
# → client_id must contain a path component, e.g. https://example.com/client.json
# dot segments
curl -s "http://localhost:3014/auth/authorize?$Q&client_id=http%3A%2F%2Flocalhost%3A3014%2Fa%2F..%2Fclient-metadata.json"
# → client_id must not contain single-dot or double-dot path segments
# a URI the document does not declare
curl -s "http://localhost:3014/auth/authorize?response_type=code&client_id=http%3A%2F%2Flocalhost%3A3014%2Fclient-metadata.json&redirect_uri=http%3A%2F%2Fevil.example%2Fcb&code_challenge=x&code_challenge_method=S256"
# → Invalid redirect_uri
The SSRF refusals (private/loopback addresses, non-https schemes) are not
visible in this demo, because allowInsecureClientIdScheme: true is exactly the
switch that disables them. Drop MCP_CIMD=1's hatch (edit main.ts) and
http://… and https://127.0.0.1/… are both refused before any connection is
attempted. tests/mcp-oauth-cimd.e2e.spec.ts covers that posture.
D. Everything at once
PORT=3014 MCP_FAKE_IDP=1 MCP_CONSENT=1 MCP_CIMD=1 npm start
Running against the workspace build? When this example is installed with a
file:dependency (scripts/use-examples.sh local) the symlinked packages resolve a second@nestjs/corefrom the workspace root, which Nest's injector rejects. AddNODE_OPTIONS=--preserve-symlinks— this is a linking artifact, not a product issue, and is whate2e/harness.tsdoes. A normalnpm installof the published packages needs nothing.
推荐服务器
Baidu Map
百度地图核心API现已全面兼容MCP协议,是国内首家兼容MCP协议的地图服务商。
Playwright MCP Server
一个模型上下文协议服务器,它使大型语言模型能够通过结构化的可访问性快照与网页进行交互,而无需视觉模型或屏幕截图。
Magic Component Platform (MCP)
一个由人工智能驱动的工具,可以从自然语言描述生成现代化的用户界面组件,并与流行的集成开发环境(IDE)集成,从而简化用户界面开发流程。
Audiense Insights MCP Server
通过模型上下文协议启用与 Audiense Insights 账户的交互,从而促进营销洞察和受众数据的提取和分析,包括人口统计信息、行为和影响者互动。
VeyraX
一个单一的 MCP 工具,连接你所有喜爱的工具:Gmail、日历以及其他 40 多个工具。
graphlit-mcp-server
模型上下文协议 (MCP) 服务器实现了 MCP 客户端与 Graphlit 服务之间的集成。 除了网络爬取之外,还可以将任何内容(从 Slack 到 Gmail 再到播客订阅源)导入到 Graphlit 项目中,然后从 MCP 客户端检索相关内容。
Kagi MCP Server
一个 MCP 服务器,集成了 Kagi 搜索功能和 Claude AI,使 Claude 能够在回答需要最新信息的问题时执行实时网络搜索。
e2b-mcp-server
使用 MCP 通过 e2b 运行代码。
Neon MCP Server
用于与 Neon 管理 API 和数据库交互的 MCP 服务器
Exa MCP Server
模型上下文协议(MCP)服务器允许像 Claude 这样的 AI 助手使用 Exa AI 搜索 API 进行网络搜索。这种设置允许 AI 模型以安全和受控的方式获取实时的网络信息。