خادم MCP بـ TypeScript: OAuth 2.1 + Streamable
١٤ مايو ٢٠٢٦
لإضافة OAuth 2.1 إلى خادم Model Context Protocol في TypeScript، قم بتشغيل @modelcontextprotocol/sdk الإصدار 1.29.0 و StreamableHTTPServerTransport خلف Express، وانشر /.well-known/oauth-protected-resource وفقًا لـ RFC 9728، وتحقق من صحة رموز bearer باستخدام jose مع فرض مؤشرات الموارد RFC 8707، وافحص authInfo.scopes داخل كل معالج أدوات (tool handler). يبني هذا الدليل العملية بالكامل من البداية إلى النهاية.
ملخص
ستقوم ببناء خادم MCP عن بُعد في TypeScript يمكن لعميل MCP (مثل Claude Desktop أو Cursor أو أي وكيل متوافق مع المواصفات) استدعاؤه عبر HTTP باستخدام رمز bearer. يعرض الخادم API مهام (tasks) بنطاقين (scopes) — هما tasks:read و tasks:write — ويستعرض كل جزء من مواصفات تفويض MCP لشهر نوفمبر 20251: البيانات الوصفية للموارد المحمية، ومؤشرات الموارد، والتفويض القائم على النطاق داخل معالجات الأدوات، وتخفيف مخاطر DNS-rebinding الذي ظهر في SDK 1.24.0. بنهاية الدليل، سيكون لديك خادم قابل للتشغيل والمراقبة ويدرك النطاقات في حوالي 300 سطر من TypeScript موزعة على خمسة ملفات صغيرة.
ما ستتعلمه
- كيفية هيكلة خادم MCP باستخدام
@modelcontextprotocol/sdk1.29.0 وzod4.4.3 - كيفية تسجيل أدوات ذات أنواع محددة (typed tools) تتلقى معالجاتها وسيط
authInfo - كيفية ربط
StreamableHTTPServerTransportخلف Express 5 مع إدارة الجلسات - كيفية نشر
/.well-known/oauth-protected-resourceوفقًا لـ RFC 9728 - كيفية التحقق من رموز الوصول باستخدام
joseوفرض مؤشرات الموارد RFC 8707 - كيفية فرض نطاقات OAuth لكل أداة داخل المعالج
- كيفية تفعيل الحماية من DNS-rebinding والقائمة البيضاء لرأس Host
- كيفية إضافة سجلات منظمة باستخدام
pinoبحيث تكون عمليات التشغيل في الإنتاج قابلة للتصحيح
المتطلبات الأساسية
- Node.js 22 LTS أو Node.js 24 LTS. يتطلب SDK إصدار Node 18+؛ Node 22 في مرحلة الصيانة (Maintenance LTS) حتى أبريل 2027 و Node 24 هو الإصدار النشط الحالي (Active LTS) حتى أكتوبر 20262. الأمثلة أدناه تثبت Node 22 لتتوافق مع سطر
@types/nodeالمثبت في الخطوة 1؛ كل شيء في هذا الدليل يعمل دون تغيير على Node 24. - npm 10 (يأتي مع Node 22) أو pnpm 10.
- خادم تفويض OAuth 2.1 يصدر رموز وصول JWT مع ادعاءات (claims)
aud، وiss، وsub، وscope، وexp. تدعم WorkOS AuthKit و Stytch مؤشر الموارد RFC 8707 بشكل أصلي؛ وتدعمه Auth0 خلف ملف تعريف توافق معلمات الموارد الاختياري؛ أما Microsoft Entra ID فلا يطبق معلمةresourceبعد ويتطلب حلاً بديلاً باستخدامscope={resource}/.default. أداة التحقق في هذا الدليل مستقلة عن المزود. - إلمام بـ TypeScript، و async/await، وأساسيات Express.
- عميل متوافق مع MCP للاختبار — واجهة سطر أوامر
@modelcontextprotocol/inspectorهي المسار الأسرع.
لا تحتاج إلى أي من: قاعدة بيانات، خادم OAuth حقيقي (تقبل أداة التحقق عنوان URL لـ JWKs الخاص بأي مزود لديك)، Docker، أو Claude Desktop. يعمل البرنامج التعليمي بالكامل على tsx.
الخطوة 1: تهيئة مشروع TypeScript
أنشئ دليلًا جديدًا وثبّت كل تبعية بالإصدارات المؤكدة في سجل npm في يوم الكتابة3:
mkdir mcp-tasks-server && cd mcp-tasks-server
npm init -y
npm install \
@modelcontextprotocol/sdk@1.29.0 \
zod@4.4.3 \
express@5.2.1 \
jose@6.2.3 \
pino@10.3.1 \
pino-pretty@13.1.3
npm install --save-dev \
TypeScript@6.0.3 \
tsx@4.21.0 \
'@types/node@^22.19.19' \
'@types/express@5.0.6'
قم بتعديل package.json لتمكين ESM وإضافة البرامج النصية:
{
"name": "mcp-tasks-server",
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/server.js"
}
}
أنشئ tsconfig.json بإعدادات NodeNext الصارمة — حيث يقوم SDK بتعيين عمليات الاستيراد بملحقات .js حتى من ملفات TypeScript، لذا فإن module: NodeNext إلزامي:
{
"compilerOptions": {
"target": "ES2023",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"declaration": false
},
"include": ["src/**/*.ts"]
}
أنشئ شجرة المصادر:
mkdir -p src/auth src/tools src/observability
الخطوة 2: تسجيل أدوات ذات أنواع محددة باستخدام McpServer و zod
يوفر SDK فئة McpServer عالية المستوى تتعامل مع JSON-RPC، والتفاوض على القدرات، وإدراج الأدوات نيابة عنك. تتلقى معالجات الأدوات وسيطًا ثانيًا يحمل authInfo — وهنا يتم فرض النطاق.
أنشئ src/tools/tasks.ts:
import { z } from "zod";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
// In-memory store keyed by OAuth subject ("sub" claim). A real server would use
// a database; the API surface is identical.
type Task = { id: string; title: string; done: boolean; ownerSub: string };
const tasks: Task[] = [];
function requireScope(authInfo: { scopes?: string[] } | undefined, scope: string) {
if (!authInfo?.scopes?.includes(scope)) {
throw new Error(`Forbidden: missing required scope "${scope}"`);
}
}
export function registerTaskTools(server: McpServer) {
server.registerTool(
"list_tasks",
{
title: "List tasks",
description: "List tasks owned by the authenticated user.",
inputSchema: { includeDone: z.boolean().optional().default(false) },
},
async ({ includeDone }, { authInfo }) => {
requireScope(authInfo, "tasks:read");
const sub = (authInfo?.extra?.sub as string) ?? "";
const mine = tasks.filter(
(t) => t.ownerSub === sub && (includeDone || !t.done),
);
return {
content: [{ type: "text", text: JSON.stringify(mine, null, 2) }],
};
},
);
server.registerTool(
"create_task",
{
title: "Create task",
description: "Create a new task owned by the authenticated user.",
inputSchema: { title: z.string().min(1).max(140) },
},
async ({ title }, { authInfo }) => {
requireScope(authInfo, "tasks:write");
const sub = (authInfo?.extra?.sub as string) ?? "";
const task: Task = {
id: crypto.randomUUID(),
title,
done: false,
ownerSub: sub,
};
tasks.push(task);
return { content: [{ type: "text", text: JSON.stringify(task) }] };
},
);
server.registerTool(
"complete_task",
{
title: "Mark task complete",
description: "Mark a task complete by id.",
inputSchema: { id: z.string().uuid() },
},
async ({ id }, { authInfo }) => {
requireScope(authInfo, "tasks:write");
const sub = (authInfo?.extra?.sub as string) ?? "";
const task = tasks.find((t) => t.id === id && t.ownerSub === sub);
if (!task) throw new Error("Task not found");
task.done = true;
return { content: [{ type: "text", text: JSON.stringify(task) }] };
},
);
}
هناك ثلاث تفاصيل مهمة هنا. أولاً، inputSchema هو كائن بسيط من مخططات zod — يقوم SDK بتكييف كل منها مع واجهة المخطط القياسي (Standard Schema) ويقوم بإنشاء JSON Schema لبروتوكول النقل4. ثانياً، الوسيط الثاني لكل معالج هو { authInfo, ... }، والذي يتم ملؤه بواسطة برمجية bearer-auth الوسيطة في الخطوة 5؛ نحن لا نثق أبداً في وسيط sub قادم من العميل. ثالثاً، فحص requireScope داخل المعالج مقصود: حتى عندما يقوم خادم التفويض (AS) بإصدار رمز بالنطاقات الصحيحة، فإنه لا يعرف الأداة التي يوشك العميل على استدعائها، لذا فإن فرض النطاق هو مسؤولية الخادم5.
الخطوة 3: ربط Streamable HTTP transport خلف Express
حل Streamable HTTP محل SSE transport المهجور في مراجعة المواصفات لشهر مارس 20256. يعمل عبر نقطة نهاية واحدة (سنستخدم /mcp) ويستخدم ثلاثة أفعال (verbs): POST لـ JSON-RPC من العميل إلى الخادم، و GET لفتح تدفق SSE للإشعارات من الخادم إلى العميل، و DELETE لإنهاء الجلسة.
أنشئ src/server.ts:
import express from "express";
import { randomUUID } from "node:crypto";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
import { registerTaskTools } from "./tools/tasks.js";
import { logger, requestIdMiddleware } from "./observability/logger.js";
import { protectedResourceMetadata } from "./auth/prm.js";
import { requireBearerToken } from "./auth/verifier.js";
const PORT = Number(process.env.PORT ?? 3333);
const PUBLIC_BASE_URL = process.env.PUBLIC_BASE_URL ?? `http://localhost:${PORT}`;
const app = express();
app.use(express.json({ limit: "1mb" }));
app.use(requestIdMiddleware);
// RFC 9728 — Protected Resource Metadata. Anonymous, must be reachable.
app.get("/.well-known/oauth-protected-resource", protectedResourceMetadata);
// Map of sessionId → transport. Streamable HTTP is stateful by default.
const transports = new Map<string, StreamableHTTPServerTransport>();
app.all("/mcp", requireBearerToken, async (req, res) => {
const sessionHeader = req.header("mcp-session-id");
let transport = sessionHeader ? transports.get(sessionHeader) : undefined;
if (!transport && req.method === "POST" && isInitializeRequest(req.body)) {
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (sid) => transports.set(sid, transport!),
enableDnsRebindingProtection: true,
allowedHosts: [`localhost:${PORT}`, `127.0.0.1:${PORT}`],
allowedOrigins: ["https://claude.ai", "https://app.cursor.com"],
});
transport.onclose = () => {
if (transport!.sessionId) transports.delete(transport!.sessionId);
};
const server = new McpServer({ name: "tasks", version: "0.1.0" });
registerTaskTools(server);
await server.connect(transport);
}
if (!transport) {
res.status(400).json({
jsonrpc: "2.0",
error: { code: -32000, message: "No active session for this request" },
id: null,
});
return;
}
await transport.handleRequest(req, res, req.body);
});
app.listen(PORT, () => {
logger.info({ port: PORT, baseUrl: PUBLIC_BASE_URL }, "mcp server listening");
});
هناك بعض النقاط التي تستحق القراءة بعناية. خريطة الجلسة موجودة في الذاكرة؛ إذا قمت بالتوسع أفقياً، فانتقل إلى sessionIdGenerator: undefined للوضع عديم الحالة (stateless) واقبل فقدان الإشعارات من الخادم إلى العميل. enableDnsRebindingProtection: true هي ميزة الأمان التي أضافتها Anthropic في SDK 1.24.07 — فهي ترفض أي طلب لا يوجد رأس Host الخاص به في allowedHosts، مما يحبط هجوم rebinding الذي وثقه Straiker في أواخر عام 2025. ضع اسم المضيف الحقيقي للإنتاج في allowedHosts وأصول عملاء MCP الموثوق بهم في allowedOrigins.
الخطوة 4: نشر البيانات الوصفية للموارد المحمية وفقًا لـ RFC 9728
تكتشف عملاء MCP مكان الحصول على الرمز من خلال جلب مستند البيانات الوصفية للموارد المحمية الخاص بالخادم. الموقع ثابت: /.well-known/oauth-protected-resource8. جعلت مراجعة المواصفات في نوفمبر 2025 نشر هذا المستند إلزامياً لخوادم MCP التي تستخدم HTTP-transport9.
أنشئ src/auth/prm.ts:
import type { Request, Response } from "express";
const PUBLIC_BASE_URL =
process.env.PUBLIC_BASE_URL ?? "http://localhost:3333";
const AUTH_SERVER = process.env.OAUTH_ISSUER!; // e.g. https://example.auth0.com/
export function protectedResourceMetadata(_req: Request, res: Response) {
res.json({
resource: `${PUBLIC_BASE_URL}/mcp`,
authorization_servers: [AUTH_SERVER],
bearer_methods_supported: ["header"],
scopes_supported: ["tasks:read", "tasks:write"],
resource_documentation: `${PUBLIC_BASE_URL}/docs`,
});
}
هناك حقلان يقومان بالدور الأهم. قيمة resource هي عنوان URL الدقيق الذي يجب على العملاء ترديده كمعلمة resource عند طلب رمز؛ إذا اختلف هذان السلسلتان، فسيفشل التحقق من صحة الجمهور RFC 8707 (بشكل صحيح) في وقت التحقق. تسرد مصفوفة authorization_servers كل خادم تفويض يثق به المورد — يختار العملاء واحداً، ويجلبون مستند /.well-known/oauth-authorization-server الخاص به (RFC 841410)، ويقومون بتشغيل تدفق كود التفويض القياسي + PKCE. لا يقوم خادم MCP نفسه بإصدار الرموز أبداً.
إذا كنت بحاجة إلى عملاء لا يدعمون التسجيل الديناميكي للعملاء (RFC 759111)، فقد أضافت مواصفات نوفمبر 2025 مسار تسجيل ثانٍ يسمى "وثائق بيانات تعريف معرف العميل" (Client ID Metadata Documents) — حيث يعرّف العملاء أنفسهم باستخدام رابط URL يتحكمون فيه12. يعتبر كل من DCR و CIMD اختياريين من وجهة نظر خادم الموارد؛ عليك فقط دعم أيهما يدعمه خادم الـ AS الخاص بك.
الخطوة 5: التحقق من رموز الوصول باستخدام jose وفرض مؤشرات الموارد
أداة التحقق صغيرة ولكن كل سطر فيها له أهميته. فهي تتحقق من توقيع JWT مقابل JWKS الخاص بـ AS، وتتحقق من صحة iss، وتتحقق من صحة aud مقابل رابط URL للمورد (هذا هو تطبيق RFC 870713)، وتتحقق من exp، وأخيرًا تشتق مصفوفة scopes من ادعاء scope.
أنشئ src/auth/verifier.ts:
import { createRemoteJWKSet, jwtVerify } from "jose";
import type { NextFunction, Request, Response } from "express";
const OAUTH_ISSUER = process.env.OAUTH_ISSUER!;
const PUBLIC_BASE_URL =
process.env.PUBLIC_BASE_URL ?? "http://localhost:3333";
const RESOURCE_URL = `${PUBLIC_BASE_URL/mcp`;
const JWKS = createRemoteJWKSet(new URL(`${OAUTH_ISSUER.well-known/jwks.json`));
export interface AuthInfo {
token: string;
clientId?: string;
scopes: string[];
expiresAt?: number;
extra: { sub?: string; iss?: string };
}
declare module "express-serve-static-core" {
interface Request {
auth?: AuthInfo;
}
}
export async function requireBearerToken(
req: Request,
res: Response,
next: NextFunction,
) {
const header = req.header("authorization") ?? "";
if (!header.toLowerCase().startsWith("bearer ")) {
return unauthorized(res, "missing_bearer_token");
}
const token = header.slice("bearer ".length);
try {
const { payload } = await jwtVerify(token, JWKS, {
issuer: OAUTH_ISSUER,
audience: RESOURCE_URL,
});
const scopes =
typeof payload.scope === "string" ? payload.scope.split(" ") : [];
req.auth = {
token,
clientId:
typeof payload.azp === "string"
? payload.azp
: typeof payload.client_id === "string"
? payload.client_id
: undefined,
scopes,
expiresAt: typeof payload.exp === "number" ? payload.exp : undefined,
extra: {
sub: typeof payload.sub === "string" ? payload.sub : undefined,
iss: typeof payload.iss === "string" ? payload.iss : undefined,
},
};
next();
} catch (err) {
return unauthorized(res, "invalid_token", (err as Error).message);
}
}
function unauthorized(res: Response, code: string, description?: string) {
// RFC 9728 requires the WWW-Authenticate challenge to point at the PRM.
res
.status(401)
.set(
"WWW-Authenticate",
`Bearer realm="mcp", error="${code", resource_metadata="${process.env.PUBLIC_BASE_URL ?? "http://localhost:3333"/.well-known/oauth-protected-resource"${description ? `, error_description="${description"` : ""`,
)
.json({ error: code, error_description: description });
}
يقوم البرنامج الوسيط (middleware) بكتابة AuthInfo المكتشفة في req.auth، والتي يقرأها StreamableHTTPServerTransport.handleRequest ويمررها إلى كل معالج أداة كمعامل authInfo كما رأينا في الخطوة 2. يتضمن ترويسة WWW-Authenticate معامل resource_metadata حتى يتمكن العميل الذكي من اكتشاف مكان الحصول على رمز مميز تلقائيًا بعد خطأ 401 — وهذا هو التسهيل الذي كُتب RFC 9728 لتمكينه14.
الخطوة 6: فرض التفويض القائم على النطاق داخل معالجات الأدوات
تعتبر الأداة المساعدة requireScope من الخطوة 2 هي النصف الثاني من القصة. تتدفق النطاقات (Scopes) من AS إلى JWT، عبر أداة التحقق، إلى req.auth.scopes، وأخيرًا إلى authInfo.scopes الخاص بمعالج الأداة. التحقق داخل المعالج مقصود: يمكن لطبقة الشبكة المصادقة، ولكن الأداة نفسها هي الوحيدة التي تعرف النطاق الذي تحتاجه.
عند ربط هذا بـ AS حقيقي، قم بإنشاء نطاقين — tasks:read و tasks:write — وتأكد من أن طلب الرمز المميز الخاص بك يتضمن scope=tasks:read tasks:write و (في المزودين الذين يدعمون ذلك) resource=https://your-host/mcp. يدعم WorkOS AuthKit معيار RFC 8707 بشكل أصلي15؛ ويدعمه Auth0 بمجرد تمكين ملف تعريف توافق معامل المورد (Resource Parameter Compatibility Profile)16؛ تحقق من وثائق المزود الخاص بك قبل الافتراض.
هناك نمطان للإنتاج يستحقان الاعتماد الآن بينما الكود لا يزال حديثًا. أولاً، لا تقرأ أبدًا sub من معاملات JSON-RPC — اسحبها دائمًا من authInfo.extra.sub. تتبع الأدوات المثال هذه القاعدة، ولهذا السبب تقوم بتخزين المهام مفهرسة بمعرف الموضوع (subject) الذي تم التحقق منه وليس بأي شيء قدمه العميل. ثانيًا، يجب أن تكون عمليات التحقق من النطاق دائمًا السطر الأول في المعالج بحيث تكون استجابات الخطأ موحدة؛ إذا قمت بنشرها لاحقًا، فسوف تنسى واحدة في النهاية.
الخطوة 7: تفعيل الحماية من إعادة ربط DNS والقائمة البيضاء للمضيفين
هجوم إعادة ربط DNS (DNS-rebinding) الذي أبلغ عنه Straiker في أواخر عام 2025 استغل أي خادم MCP يستمع على localhost: حيث دفع موقع ويب ضار المتصفح لتحليل اسم مضيف تحت سيطرته إلى 127.0.0.1 وقام بتشغيل الأدوات ضد الخادم المحلي بهوية الشبكة الخاصة بالمستخدم الزائر17. أطلقت Anthropic الحل في @modelcontextprotocol/sdk 1.24.0 — وهو التحقق من ترويسة Host الذي يرفض أي طلب لا توجد ترويسة Host الخاصة به في قائمة allowedHosts المهيأة. الحماية اختيارية (معطلة افتراضيًا للتوافق مع الإصدارات السابقة، وفقًا للتوصية CVE-2025-66414) ما لم تستخدم المساعد createMcpExpressApp()، لذا قمنا بضبطها صراحةً على وسيلة النقل (transport).
خيارات StreamableHTTPServerTransport المستخدمة في الخطوة 3 تمكن الحماية بالفعل:
new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (sid) => transports.set(sid, transport!),
enableDnsRebindingProtection: true,
allowedHosts: [`localhost:${PORT`, `127.0.0.1:${PORT`],
allowedOrigins: ["https://claude.ai", "https://app.cursor.com"],
});
بالنسبة لنشر الإنتاج، استبدل مدخلات localhost باسم المضيف الحقيقي الخاص بك وأضف كل أصل (origin) لعميل MCP تنوي دعمه إلى allowedOrigins. تقوم كلتا القائمتين بمطابقة سلاسل نصية صارمة — الرموز البديلة (wildcards) غير مدعومة عمدًا. توصي وثيقة أفضل ممارسات أمان MCP صراحةً بتهيئة كلتا القائمتين في الإنتاج18.
هناك وسيلة تقوية ثانية تستحق الإضافة إلى نفس تطبيق Express: تحديد معدل الطلبات (rate limiting). يعتمد SDK بالفعل على express-rate-limit؛ أبسط إعداد للإنتاج هو تثبيته على /mcp بحد أقصى 60 طلبًا في الدقيقة لكل عنوان IP للطلبات غير المصادق عليها وحد أعلى للطلبات المصادق عليها.
الخطوة 8: القابلية للملاحظة المنظمة باستخدام pino ومعرفات الطلبات
تفشل استدعاءات الأدوات بطرق غامضة عندما لا توجد سجلات. الحد الأدنى من النمط المفيد هو برنامج وسيط لمعرف الطلب (request-id) يربط معرفًا بكل طلب ومثيل pino يصدر سطرًا واحدًا منظمًا لكل استدعاء أداة.
أنشئ src/observability/logger.ts:
import pino from "pino";
import { randomUUID } from "node:crypto";
import type { NextFunction, Request, Response } from "express";
export const logger = pino({
level: process.env.LOG_LEVEL ?? "info",
transport:
process.env.NODE_ENV === "production"
? undefined
: { target: "pino-pretty", options: { translateTime: "SYS:HH:MM:ss.l" } },
base: { service: "mcp-tasks-server" },
});
declare module "express-serve-static-core" {
interface Request {
log?: pino.Logger;
requestId?: string;
}
}
export function requestIdMiddleware(
req: Request,
res: Response,
next: NextFunction,
) {
const requestId =
req.header("x-request-id") ?? randomUUID();
req.requestId = requestId;
req.log = logger.child({ requestId });
res.setHeader("x-request-id", requestId);
const started = process.hrtime.bigint();
res.on("finish", () => {
const latencyMs = Number(process.hrtime.bigint() - started) / 1_000_000;
req.log!.info(
{
method: req.method,
path: req.path,
status: res.statusCode,
latencyMs: Math.round(latencyMs * 100) / 100,
clientId: req.auth?.clientId,
userSub: req.auth?.extra?.sub,
},
"request",
);
});
next();
}
عندما يكون هذا في مكانه، يصدر كل طلب سطر سجل JSON واحدًا في الإنتاج وسطرًا ملونًا في التطوير. يمرر كل من MCP Inspector و Claude Desktop ترويسة x-request-id التي قمت بتعيينها في الاستجابة، لذا يعمل الارتباط من البداية إلى النهاية دون الحاجة إلى المزيد من التوصيلات.
التحقق
قم بتشغيل الخادم باستخدام رابط URL حقيقي للمصدر (issuer). يستخدم البرنامج التعليمي auth.example.com كبديل — استبدله بـ AS الخاص بك:
OAUTH_ISSUER=https://auth.example.com/ \
PUBLIC_BASE_URL=http://localhost:3333 \
npm run dev
في نافذة أوامر ثانية، تأكد من إمكانية الوصول إلى وثيقة بيانات تعريف المورد المحمي:
curl -s http://localhost:3333/.well-known/oauth-protected-resource | jq .
المخرجات المتوقعة:
{
"resource": "http://localhost:3333/mcp",
"authorization_servers": ["https://auth.example.com/"],
"bearer_methods_supported": ["header"],
"scopes_supported": ["tasks:read", "tasks:write"],
"resource_documentation": "http://localhost:3333/docs"
}
تأكد من أن الطلبات المجهولة تعيد خطأ 401 مع تحدي WWW-Authenticate يشير مرة أخرى إلى PRM:
curl -i -X POST http://localhost:3333/mcp \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","method":"initialize","params":{},"id":1}'
يجب أن ترى:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="mcp", error="missing_bearer_token", resource_metadata="http://localhost:3333/.well-known/oauth-protected-resource"
لتجربة المسار الناجح بالكامل، قم بتشغيل Inspector الرسمي ضد الخادم. اكتب ملف تكوين يصف الخادم البعيد:
// mcp.json
{
"mcpServers": {
"tasks": {
"type": "streamable-http",
"url": "http://localhost:3333/mcp"
}
}
}
ثم قم بتشغيل Inspector مشيرًا إلى ذلك التكوين:
npx @modelcontextprotocol/inspector --config mcp.json --server tasks
يبدأ Inspector واجهة مستخدم ويب محلية على http://localhost:6274. افتحها، والصق رمز الحامل (bearer token) الخاص بك في حقل ترويسة "Authorization" في لوحة الاتصال، وانقر على "Connect". بمجرد الاتصال، تسرد واجهة المستخدم الأدوات الثلاث وتسمح لك باستدعاء create_task و list_tasks و complete_task ضد الخادم المباشر. راقب سجلات pino الخاصة بك — كل مكالمة تصدر سطرًا منظمًا واحدًا يحمل clientId و userSub و latencyMs.
إذا كنت تفضل البرمجة النصية، فإن نفس Inspector يوفر وضع --cli لخوادم stdio؛ اختبار HTTP عن بُعد هو الأسهل من خلال واجهة المستخدم، أو يمكنك تشغيل نقطة نهاية JSON-RPC مباشرة باستخدام curl بمجرد حصولك على معرف جلسة من استجابة التهيئة (initialize).
الأخطاء الشائعة
هناك بعض الأشياء التي قد تواجه المطورين لأول مرة. كل واحدة من هذه المشاكل واجهتني أو واجهت شخصًا أعمل معه في بناء MCP حقيقي في عام 2026.
Error: Invalid Host header — العميل الخاص بك يرسل قيمة Host ليست موجودة في allowedHosts. إما أن تضيف المضيف إلى القائمة أو، إذا كنت تعمل خلف موازن تحميل (load balancer) يقوم بإعادة كتابة الـ Host، فقم بتعيين allowedHosts على القيمة المرئية خارجيًا. لا تقم بتعطيل enableDnsRebindingProtection؛ حيث تدرج مواصفات نوفمبر 2025 التخفيف من إعادة الربط (rebinding mitigation) كـ SHOULD19.
Error: audience claim check failed — خيار audience الخاص بـ jose يقارن مقابل ادعاء (claim) الـ aud في JWT. تتطلب مواصفات MCP أن يكون aud هو عنوان URL للمورد الذي أصدر الـ AS الرمز (token) من أجله. تأكد من ثلاثة أشياء: (1) أن عميلك قام بتضمين resource=http://localhost:3333/mcp في طلب الرمز، (2) أن الـ AS الخاص بك يدعم RFC 8707، و (3) أن القيمة في أداة التحقق (verifier) الخاصة بك تطابق حقل resource في مستند PRM حرفًا بحرف.
Cannot find module ... .js — يتطلب TypeScript مع module: NodeNext وجود امتدادات .js في عمليات الاستيراد النسبية حتى من ملفات .ts. هذه ضريبة تعلم لمرة واحدة؛ بمجرد تكوين الإكمال التلقائي في المحرر الخاص بك، ستختفي المشكلة.
قائمة الأدوات تعود فارغة — غالبًا ما تكون مشكلة في الجلسة (session). تأكد من أن استجابة طلب POST /mcp الأولي تتضمن ترويسة mcp-session-id وأن عميلك يقوم بإرسالها في الطلبات اللاحقة. يقوم الـ Inspector بذلك تلقائيًا؛ بينما لا يفعل curl ذلك.
المعالج (handler) يرمي خطأ "Forbidden: missing required scope" — أصدر الـ AS رمزًا بدون الصلاحيات (scopes) التي طلبتها. كل من Auth0 و Entra يقيدان الصلاحيات خلف موافقة صريحة وتسجيل API؛ تأكد مرة أخرى من تسجيل الصلاحية على مورد API وأن تطبيق العميل مسموح له بطلبها.
MODULE_NOT_FOUND لـ @modelcontextprotocol/sdk/server/auth/... — شائع عند نسخ ولصق مقتطفات برمجية قديمة. يجب أن تنتهي عمليات الاستيراد بـ .js، وليس .ts أو بدون امتداد. توفر الـ SDK نقطة دخول ESM ويفرض Node بدقة الامتدادات من أجل دقة NodeNext.
الخطوات التالية
لديك الآن خادم MCP قابل للتشغيل يمكن لعميل MCP المصادقة عليه، ويفرض مؤشرات موارد RFC 8707، وينشر البيانات الميتا للموارد المحمية RFC 9728، ويرفض محاولات إعادة ربط DNS عبر القائمة البيضاء لترويسة Host، ويصدر سجلات منظمة يمكنك توجيهها إلى أي نظام مراقبة (observability stack).
إليك بعض المتابعات الطبيعية:
- استبدل مصفوفة
tasksالموجودة في الذاكرة بقاعدة بيانات حقيقية. إذا كنت تريد مثالاً عمليًا لأنماط Postgres التي تحافظ على حالة الجلسة والتي تعمل بشكل جيد مع Streamable HTTP، فإن درس التواجد في الوقت الفعلي باستخدام Postgres LISTEN/NOTIFY يغطي دورة حياة الاتصال طويل الأمد التي تريدها هنا. - ادفع الخادم إلى الحافة (edge). يشرح درس Cloudflare Workers R2 image CDN أساسيات Workers التي تترجم تقريبًا حرفيًا لتشغيل خادم MCP على مسار Workers — حيث يقبل
StreamableHTTPServerTransportالخاص بالـ SDK بالفعل وضعًا عديم الحالة (stateless) للعمليات بدون خادم (serverless). - قم بتشغيل الخادم خلف خلفية Postgres مجمعة (pooled) عندما تنتقل من
Map<string, transport>إلى نشر متعدد النسخ (multi-instance). راجع تجميع Postgres للإنتاج باستخدام PgBouncer و Supavisor لمعرفة أنماط تجميع الاتصالات التي تتحمل التوسع الأفقي. - للحصول على خلفية مفاهيمية حول ماهية MCP وموقعه في بنية الوكيل (agent stack)، اقرأ نظرة عامة على شرح خوادم MCP.
بمجرد تشغيل الخادم، فإن الشيء التالي الذي تريده كل الفرق هو عميل MCP مخصص يمكنه استدعاؤه برمجياً؛ هذا دليل ليوم آخر، لكن تدفق المصادقة الذي بنيته للتو هو بالضبط ما سيستهلكه العميل في الطرف الآخر.
Footnotes
-
Model Context Protocol Authorization specification (current draft tracking the November 25, 2025 revision). https://modelcontextprotocol.io/specification/draft/basic/authorization ↩
-
Node.js release schedule — Node 24 is the current Active LTS through October 2026; Node 22 is in Maintenance LTS through April 2027. https://nodejs.org/en/about/previous-releases ↩
-
@modelcontextprotocol/sdk1.29.0 on the npm registry, verified May 14, 2026. https://www.npmjs.com/package/@modelcontextprotocol/sdk ↩ -
MCP TypeScript SDK server documentation — tool registration with Standard Schema input. https://GitHub.com/modelcontextprotocol/TypeScript-sdk/blob/main/docs/server.md ↩
-
MCP Authorization spec — clients SHOULD request the minimum scope; servers MUST enforce. https://modelcontextprotocol.io/specification/draft/basic/authorization ↩
-
MCP transports specification — Streamable HTTP replaces SSE (2025-03-26). https://modelcontextprotocol.io/specification/2025-03-26/basic/transports ↩
-
@modelcontextprotocol/sdk1.24.0 release notes — DNS rebinding protection added. https://GitHub.com/modelcontextprotocol/TypeScript-sdk/releases ↩ -
RFC 9728 — OAuth 2.0 Protected Resource Metadata. https://datatracker.ietf.org/doc/html/rfc9728 ↩
-
Auth0 — MCP specs update covering the June 18, 2025 and November 25, 2025 authorization revisions. https://auth0.com/blog/mcp-specs-update-all-about-auth/ ↩
-
RFC 8414 — OAuth 2.0 Authorization Server Metadata. https://datatracker.ietf.org/doc/html/rfc8414 ↩
-
RFC 7591 — OAuth 2.0 Dynamic Client Registration Protocol. https://datatracker.ietf.org/doc/html/rfc7591 ↩
آرون باريكي — تسجيل العميل وإدارة المؤسسات في مواصفة تفويض MCP لشهر نوفمبر 2025. https://aaronparecki.com/2025/11/25/1/mcp-authorization-spec-update ↩
RFC 8707 — مؤشرات الموارد لبروتوكول OAuth 2.0. https://datatracker.ietf.org/doc/html/rfc8707 ↩
دين ديليمارسكي — ما الجديد في مواصفة تفويض MCP بتاريخ 25-11-2025. https://den.dev/blog/mcp-november-authorization-spec/ ↩
WorkOS — مؤشرات الموارد لتفويض MCP (AuthKit). https://workos.com/changelog/resource-indicators-for-mcp-auth ↩
Auth0 — ملف تعريف توافق بارامتر الموارد لـ MCP. https://auth0.com/ai/docs/mcp/guides/resource-param-compatibility-profile ↩
Straiker — خطر الوكلاء: إعادة ربط DNS يكشف خوادم MCP الداخلية. https://www.straiker.ai/blog/agentic-danger-dns-rebinding-exposing-your-internal-mcp-servers ↩
MCP — أفضل ممارسات الأمان. https://modelcontextprotocol.io/docs/tutorials/security/security_best_practices ↩
مواصفة تفويض MCP — تخفيف مخاطر إعادة ربط DNS والتحقق من Host-header. https://modelcontextprotocol.io/specification/draft/basic/authorization ↩