Protecting Your Server
หน้าที่ของ server: ตรวจสอบ ไม่ใช่ออก token
หัวข้อที่มีชื่อว่า “หน้าที่ของ server: ตรวจสอบ ไม่ใช่ออก token”HTTP MCP server คือ resource server ตาม OAuth 2.1 มันไม่ได้ log user เข้าระบบ และไม่ได้ออก token authorization server เป็นคนทำเรื่องพวกนั้น หน้าที่ทั้งหมดของ server คือตอบคำถามเดียวในทุก ๆ request: token นี้ใช้ได้ไหม สำหรับฉัน สำหรับ operation นี้
flowchart LR
R["POST /mcp"] --> T{"มี bearer token ไหม"}
T -->|ไม่มี| E401["401 + WWW-Authenticate"]
T -->|มี| V{"signature และวันหมดอายุผ่านไหม"}
V -->|ไม่ผ่าน| E401
V -->|ผ่าน| A{"audience คือ server นี้ไหม"}
A -->|ไม่ใช่| E401
A -->|ใช่| S{"scope ครอบคลุม operation ไหม"}
S -->|ไม่| E403["403 insufficient_scope"]
S -->|ใช่| H["MCP handler รัน request"] การตรวจตามลำดับ ตาม OAuth 2.1 section 5.2 และ RFC 8707:
- มีและถูกรูปแบบ header
Authorization: Bearerที่หายไปหรือผิดรูปแบบได้401พร้อมWWW-Authenticatechallenge ที่ชี้ไปยัง resource metadata ของคุณ - signature และวันหมดอายุ ตรวจกับ key ของ AS (JWT) หรือผ่าน introspection (opaque token) ไม่ถูกต้องหรือหมดอายุคือ
401 - audience token MUST ถูกออกให้ server ตัวนี้ ซึ่งระบุด้วย canonical URI ของมัน นี่คือการตรวจที่ทำให้ token passthrough เป็นไปไม่ได้ audience ผิดคือ
401ไม่ใช่ “รับไว้แต่จำกัดสิทธิ์” - scope ถ้า operation ต้องการ scope ที่ token ไม่มี ตอบ
403พร้อมerror="insufficient_scope"และชุด scope ทั้งหมดที่ต้องการ เพื่อให้ client step-up ได้ในรอบเดียว
จากนี้ตามมาด้วยกฎเด็ดขาดสองข้อ: server MUST NOT รับ token ที่ไม่ได้ออกให้ตัวเอง และ MUST NOT ส่งต่อ token ใด ๆ ที่ได้รับไปยัง service อื่น
สาม endpoint ที่คุณต้อง host
หัวข้อที่มีชื่อว่า “สาม endpoint ที่คุณต้อง host”| Path | หน้าที่ |
|---|---|
/mcp (MCP endpoint ของคุณ) | protected resource ทุก POST ถูกตรวจตามข้างบน |
/.well-known/oauth-protected-resource/mcp | Protected Resource Metadata ตาม RFC 9728 (แบบมี path; แบบ root ก็ใช้ได้) ระบุ authorization_servers และ scopes_supported ของคุณ |
/.well-known/oauth-authorization-server (mirror, ไม่บังคับ) | ถ้า AS อยู่ที่เดียวกัน คุณอาจ serve metadata ตาม RFC 8414 ของมันตรงนี้ด้วย |
SDK ทั้งสองตัวให้ของพวกนี้ได้ในไม่กี่บรรทัด
ต่อเข้ากับ SDK
หัวข้อที่มีชื่อว่า “ต่อเข้ากับ SDK”helper ของ Express ให้ทั้ง metadata router และ bearer middleware คุณแค่ใส่ verifier ซึ่งเป็นส่วนเดียวที่รู้จัก AS ของคุณ
import type { OAuthTokenVerifier } from '@modelcontextprotocol/express';import { createMcpExpressApp, getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter, requireBearerAuth} from '@modelcontextprotocol/express';import { toNodeHandler } from '@modelcontextprotocol/node';import type { AuthInfo, OAuthMetadata } from '@modelcontextprotocol/server';import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
const mcpServerUrl = new URL('https://api.example.com/mcp');
// Your verifier: check signature, expiry, audience; return AuthInfo or throw.async function verifyAccessToken(token: string): Promise<AuthInfo> { const claims = await verifyJwt(token, { audience: mcpServerUrl.href }); return { token, clientId: claims.client_id, scopes: claims.scope.split(' ') };}const verifier: OAuthTokenVerifier = { verifyAccessToken };
const auth = requireBearerAuth({ verifier, requiredScopes: ['mcp'], resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl)});
const app = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['api.example.com'] });app.use(mcpAuthMetadataRouter({ oauthMetadata, resourceServerUrl: mcpServerUrl }));
const node = toNodeHandler(createMcpHandler(buildServer));app.all('/mcp', auth, (req, res) => void node(req, res, req.body));mcpAuthMetadataRouter serve /.well-known/oauth-protected-resource/mcp (RFC 9728) และ mirror oauthMetadata ที่คุณส่งให้ไว้ที่ /.well-known/oauth-authorization-server ส่วน requireBearerAuth ตอบ 401/403 พร้อม WWW-Authenticate challenge ที่ถูกต้อง และแนบ AuthInfo ที่ตรวจแล้วไว้กับ request
ภายใน tool ตัวตนของผู้เรียกมาถึงผ่าน request context:
server.registerTool('whoami', { description: 'Report the authenticated caller' }, async ctx => { const caller = ctx.http?.authInfo; return { content: [{ type: 'text', text: `${caller?.clientId} [${caller?.scopes.join(' ')}]` }] };});สัญญาของ Python SDK คือ protocol TokenVerifier ที่มี async method ตัวเดียว คืน AccessToken สำหรับ token ที่ดี และ None สำหรับอย่างอื่นทั้งหมด
from mcp.server import MCPServerfrom mcp.server.auth.provider import AccessToken, TokenVerifierfrom mcp.server.auth.settings import AuthSettings
class JwtVerifier(TokenVerifier): async def verify_token(self, token: str) -> AccessToken | None: try: claims = verify_jwt(token, audience="https://api.example.com/mcp") except Exception: return None # invalid signature, expired, wrong audience -> 401 return AccessToken( token=token, client_id=claims["client_id"], scopes=claims["scope"].split(), expires_at=claims["exp"], )
mcp = MCPServer( "Files", token_verifier=JwtVerifier(), auth=AuthSettings( issuer_url="https://auth.example.com", resource_server_url="https://api.example.com/mcp", required_scopes=["mcp"], ),)AuthSettings กำหนดหน้าตาที่ server เปิดออกสู่ภายนอก: จะประกาศ AS ตัวไหนใน Protected Resource Metadata (issuer_url), canonical URI ที่ใช้เป็น audience (resource_server_url) และ scope ที่ทุก token ต้องมี รันผ่าน Streamable HTTP ด้วย mcp.run(transport="streamable-http", host="0.0.0.0", port=8000) แล้ว well-known route กับ challenge จะถูก serve ให้เอง
ภายใน tool อ่าน token ที่ authenticate แล้วจาก request context:
from mcp.server.auth.middleware.auth_context import get_access_token
@mcp.tool()def whoami() -> str: """Report the authenticated caller.""" token = get_access_token() if token is None: return "anonymous" return f"{token.client_id} [{' '.join(token.scopes)}]"ไม่ว่าจะใช้ SDK ไหน verifier คือที่ที่การตรวจ audience อาศัยอยู่ อย่าข้ามมันด้วยเหตุผลว่า “AS ออก token ให้เราคนเดียว” เพราะทันทีที่มี resource ตัวที่สองอยู่หลัง AS เดียวกัน สมมติฐานนั้นกลายเป็นช่องโหว่
authorization เป็นแบบต่อ request และรายการอาจต่างกันได้
หัวข้อที่มีชื่อว่า “authorization เป็นแบบต่อ request และรายการอาจต่างกันได้”MCP เป็น stateless: credential เป็น input ต่อ request ไม่ใช่ state ของ connection นั่นมีผลพวงที่มีประโยชน์ซึ่ง spec ระบุไว้ชัด tools/list, resources/list และ prompts/list MUST NOT ต่างกันตาม connection แต่ MAY ต่างกันตาม authorization ที่ยื่นมากับ request token ที่มี files:read อาจเห็นรายการ tool สั้นกว่า token ที่มี files:write ได้อย่างชอบธรรม
ทำแบบนี้ด้วยการกรองตาม scope ที่ตรวจแล้วภายใน list handler หรือตอน register tool ห้ามเชื่อสิ่งที่ client ส่งมาใน _meta หรือใน argument ของ tool field clientInfo เป็นค่าที่ client รายงานเอง และ MUST NOT ถูกใช้ตัดสินใจเรื่อง security
การออกแบบ scope
หัวข้อที่มีชื่อว่า “การออกแบบ scope”scope ที่ออกแบบไม่ดีเป็นปัญหา security ไม่ใช่แค่ UX: token ที่กว้างเกินไปซึ่งถูกขโมยจาก log จะให้สิทธิ์เข้าถึงทุกอย่างในคราวเดียว คำแนะนำของ spec คือโมเดลแบบ progressive และ least-privilege:
เริ่มจากเล็ก scopes_supported ใน resource metadata ควรระบุเฉพาะชุดขั้นต่ำสำหรับการใช้งานพื้นฐาน (เช่น mcp:tools-basic สำหรับ discovery และ tool ที่อ่านอย่างเดียว) ไม่ใช่รายการทั้งหมดของคุณ
challenge ให้แม่น เมื่อ tool ต้องการมากกว่านั้น ตอบ 403 พร้อม scope ทั้งหมด ที่ operation นั้นต้องการ ในคราวเดียว การ challenge ทีละ scope บังคับให้มี consent หลายรอบสำหรับ action เดียว
HTTP/1.1 403 ForbiddenWWW-Authenticate: Bearer error="insufficient_scope", scope="files:write files:delete", resource_metadata="https://api.example.com/.well-known/oauth-protected-resource/mcp"คุณจะเลือกแบบขั้นต่ำ (เฉพาะที่ operation นี้ต้องการ), แบบกลาง ๆ ที่แนะนำ (operation นี้บวก scope ที่มักมาด้วยกัน) หรือแบบขยายที่ client น่าจะต้องใช้เร็ว ๆ นี้ก็ได้ เลือกแบบไหนก็ตาม ให้สม่ำเสมอเพื่อให้ client คาดเดาคุณได้
รับ token ที่ scope น้อยกว่าที่ขอ AS MAY ให้เพียงส่วนหนึ่งของที่ขอ server ของคุณควรทำงานได้กับสิทธิ์ที่น้อยลง ไม่ใช่ล้มทันทีที่ scope เสริมตัวแรกหายไป
เคารพลำดับชั้น ถ้า AS ของคุณนิยามว่า admin ครอบคลุม read token ที่มี admin ต้องผ่านการตรวจ read ด้วย เข้ารหัส mapping นั้นฝั่ง server อย่าพึ่ง client ให้ขยายมันเอง
อย่าเชื่อ scope ที่อ้างมาเพียงอย่างเดียว scope ใน token บอกว่า user ยินยอม ให้ทำ action ประเภทนั้น server ของคุณยังต้องตัดสินว่า user คนนี้ แตะ resource ชิ้นนี้ ได้ไหม ด้วย authorization ตาม business logic ปกติ
ข้อผิดพลาดที่พบบ่อยตามที่ spec ระบุ: ประกาศทุก scope ใน scopes_supported, scope ครอบจักรวาลอย่าง * หรือ full-access, มัดสิทธิ์ที่ไม่เกี่ยวกันรวมกันเพื่อเลี่ยง prompt ในอนาคต, คืนรายการทั้งหมดในทุก challenge และเปลี่ยนความหมายของ scope เงียบ ๆ โดยไม่ทำ versioning
สุขอนามัยในการปฏิบัติงาน
หัวข้อที่มีชื่อว่า “สุขอนามัยในการปฏิบัติงาน”นอกจากการตรวจ token spec ของ tools บอกว่า server MUST จำกัดอัตราการเรียก, ตรวจ input, ทำ access control และ sanitize output สำหรับ server ที่ authenticate แล้วในทางปฏิบัติ:
- rate limit ต่อ principal โดยใช้
client_idหรือ subject ที่ตรวจแล้ว ไม่ใช่ต่อ IP - log การใช้ tool พร้อม principal และ correlation ID log การยกระดับ scope (ที่ขอ, ส่วนที่ได้รับ) เพื่อให้ audit สร้างภาพได้ว่าใครยกระดับอะไร
- ใช้ TLS bearer token ปลอดภัยได้แค่เท่าที่ช่องทางปลอดภัย
https://เป็นข้อบังคับสำหรับ production และhttp://ธรรมดายอมให้ได้เฉพาะบน loopback ตอนพัฒนา - ผูก state handle กับ user ถ้า tool คืน handle ไว้ใช้ในการเรียกครั้งถัดไป เก็บ state ด้วย key แบบ
user_id:handleโดยที่user_idมาจาก token ที่ตรวจแล้ว การถือ handle ไม่ใช่ authentication เด็ดขาด