دانيلا (⁦Dayfing⁩)
العودة إلى المقالات
2,133 كلمة13 د

خادم MCP بلغة TypeScript: ‏Streamable HTTP وOAuth وأقل قدر من الصلاحيات

يبني هذا الدليل خادماً صغيراً بعيداً لـMCP من أجل خدمة ملاحظات. يستخدم المثال الفرع المستقر v2 من TypeScript SDK، وهو الفرع الذي يطبق مراجعة MCP 2026-07-28. يقبل الخادم طلبات Streamable HTTP الحديثة، ويتحقق من access token الخاص بـOAuth بصفته خادم موارد، ويعرض أداتي القراءة والحذف، ويتحقق من الوسائط بواسطة Zod، ويضع فحص الصلاحية بجوار العملية التي يحميها. الكود مثال قابل للتنفيذ، لكن المقال لا يدعي أنه شُغّل في بيئتك.

إصدار البروتوكول مهم هنا. حزم v2 مقسمة إلى @modelcontextprotocol/server و@modelcontextprotocol/client و@modelcontextprotocol/core ومحولات بيئة التشغيل. لا تنسخ إلى خدمة جديدة أمثلة قديمة تستورد @modelcontextprotocol/sdk/server/mcp.js. يشرح دليل ترحيل MCP 2026-07-28 تغييرات السلك والحزم بمزيد من التفصيل.

ابدأ بنموذج السلك الحديث

يستخدم Streamable HTTP نقطة MCP واحدة، عادةً /mcp، ويُرسل كل طلب JSON-RPC من العميل في HTTP POST مستقل. يكون الرد كائن JSON واحداً أو تدفق SSE خاصاً بذلك الطلب. تؤكد الإشعارات بالحالة 202 من دون جسم. يعلن العميل عن النوعين application/json وtext/event-stream في Accept، ويرسل JSON مع Content-Type: application/json.

تحذف مراجعة 2026-07-28 تدفق GET القديم، ومعرّفات الجلسة على مستوى البروتوكول، وإمكانية الاستئناف باستخدام سجل Last-Event-ID. كما تحذف طلبات JSON-RPC المستقلة من الخادم إلى العميل. إذا احتاجت أداة إلى تأكيد أو قيمة إضافية، تعيد نتيجة input_required. يجيب العميل عن الطلب المضمّن ثم يعيد استدعاء الطلب الأصلي. أما إشعارات تغيّر القوائم طويلة العمر فتستخدم تدفق استجابة subscriptions/listen، وليس اتصال GET عاماً.

يحتوي كل POST حديث على MCP-Protocol-Version، ويجب أن تطابق قيمته _meta.io.modelcontextprotocol/protocolVersion داخل جسم JSON. يعكس Mcp-Method طريقة JSON-RPC في كل طلب. ويعكس Mcp-Name القيمة params.name أو params.uri لطلبات tools/call وresources/read وprompts/get. قد تستخدم البوابة هذه الرؤوس للتوجيه، لكن على التطبيق أن يرفض أي اختلاف بين الرؤوس والجسم. يبقى الجسم مصدر الحقيقة.

تبدو مكالمة حديثة صغيرة هكذا:

POST /mcp HTTP/1.1
Accept: application/json, text/event-stream
Content-Type: application/json
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: notes.search

{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"notes.search","arguments":{"query":"gateway"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"notes-cli","version":"1.0.0"},"io.modelcontextprotocol/clientCapabilities":{}}}}

ثبّت SDK v2

أنشئ مشروع Node.js بنمط ESM باستخدام Node.js 20 أو أحدث، وTypeScript 6، وحزم SDK. ثبّت الإصدارات في lockfile الخاص بالتطبيق. عند فحص هذا المقال كان إصدار حزمة الخادم المستقرة ومحولَي Node وExpress هو 2.0.0. تستخدم حزمة الخادم Zod 4 عند استعمال مخططات Zod.

npm install @modelcontextprotocol/[email protected] @modelcontextprotocol/[email protected] @modelcontextprotocol/[email protected] express@5 zod@4
npm install --save-dev typescript@6 @types/node @types/express tsx

مع TypeScript 6 أضف أنواع Node.js صراحةً إذا لم تضفها إعداداتك بعد:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "types": ["node"]
  }
}

ابنِ الخادم وحاجز خادم الموارد

تستقبل createMcpHandler دالة factory وتنشئ McpServer جديداً لكل طلب HTTP. يكيف toNodeHandler واجهة fetch القياسية للويب مع كائنات الطلب والرد في Node. يوفر محول Express تحليل JSON وحماية host وorigin للمضيف المكوّن وmiddleware الخاص بـOAuth.

خادم MCP هو خادم موارد OAuth. يتحقق من الرموز التي يصدرها authorization server ولا يصدر الرموز بنفسه. يستخدم المتحقق التالي introspection وفق RFC 7662. فهو يفحص عمداً active وsub وexp، ويحوّل قيمة scope المفصولة بمسافات، ويعيد AuthInfo الخاصة بـSDK. لا تستبدل ذلك بتحقق JWT محلي قبل تنفيذ واختبار issuer وaudience والتوقيعات والمفاتيح وسياسة الساعة وتدوير المفاتيح.

import {
  createMcpExpressApp,
  getOAuthProtectedResourceMetadataUrl,
  mcpAuthMetadataRouter,
  requireBearerAuth,
  type OAuthTokenVerifier
} from "@modelcontextprotocol/express";
import { toNodeHandler } from "@modelcontextprotocol/node";
import {
  createMcpHandler,
  McpServer,
  OAuthError,
  OAuthErrorCode,
  type AuthInfo,
  type OAuthMetadata
} from "@modelcontextprotocol/server";
import type { Request, Response } from "express";
import * as z from "zod/v4";

function required(name: string): string {
  const value = process.env[name];
  if (!value) throw new Error(`Missing ${name}`);
  return value;
}

const host = process.env.HOST ?? "127.0.0.1";
const mcpUrl = new URL(required("MCP_URL"));
const scopes = ["mcp", "notes:read", "notes:write"];
const oauthMetadata: OAuthMetadata = {
  issuer: required("OAUTH_ISSUER"),
  authorization_endpoint: required("OAUTH_AUTHORIZATION_ENDPOINT"),
  token_endpoint: required("OAUTH_TOKEN_ENDPOINT"),
  response_types_supported: ["code"],
  scopes_supported: scopes,
  code_challenge_methods_supported: ["S256"],
  authorization_response_iss_parameter_supported: true
};
const notes = [
  { id: "1", text: "Rotate the signing key after the release." },
  { id: "2", text: "Review the MCP gateway rate limit." }
];

async function verifyAccessToken(token: string): Promise<AuthInfo> {
  const credentials = Buffer.from(`${required("OAUTH_CLIENT_ID")}:${required("OAUTH_CLIENT_SECRET")}`).toString("base64");
  const response = await fetch(required("OAUTH_INTROSPECTION_URL"), {
    method: "POST",
    headers: {
      authorization: `Basic ${credentials}`,
      "content-type": "application/x-www-form-urlencoded"
    },
    body: new URLSearchParams({ token }).toString()
  });
  if (!response.ok) throw new OAuthError(OAuthErrorCode.InvalidToken, "Token introspection failed");
  const payload = await response.json() as {
    active?: unknown;
    sub?: unknown;
    client_id?: unknown;
    scope?: unknown;
    exp?: unknown;
  };
  if (payload.active !== true || typeof payload.sub !== "string" || typeof payload.exp !== "number") {
    throw new OAuthError(OAuthErrorCode.InvalidToken, "Token is inactive or incomplete");
  }
  const tokenScopes = typeof payload.scope === "string" ? payload.scope.split(/\s+/).filter(Boolean) : [];
  return {
    token,
    clientId: typeof payload.client_id === "string" ? payload.client_id : payload.sub,
    scopes: tokenScopes,
    expiresAt: payload.exp
  };
}

const verifier: OAuthTokenVerifier = { verifyAccessToken };

function buildServer(): McpServer {
  const server = new McpServer({ name: "notes", version: "1.0.0" });
  server.registerTool(
    "notes.search",
    {
      title: "Search notes",
      description: "Find notes containing a phrase.",
      inputSchema: z.object({
        query: z.string().trim().min(1).max(200),
        limit: z.number().int().min(1).max(20).default(10)
      })
    },
    async ({ query, limit }, ctx) => {
      if (!ctx.http?.authInfo?.scopes.includes("notes:read")) {
        return { content: [{ type: "text", text: "insufficient_scope: notes:read is required" }], isError: true };
      }
      const needle = query.toLocaleLowerCase();
      const matches = notes.filter(note => note.text.toLocaleLowerCase().includes(needle)).slice(0, limit);
      return { content: [{ type: "text", text: JSON.stringify(matches) }] };
    }
  );
  server.registerTool(
    "notes.delete",
    {
      title: "Delete a note",
      description: "Delete one note by id.",
      inputSchema: z.object({ id: z.string().regex(/^\d+$/) })
    },
    async ({ id }, ctx) => {
      if (!ctx.http?.authInfo?.scopes.includes("notes:write")) {
        return { content: [{ type: "text", text: "insufficient_scope: notes:write is required" }], isError: true };
      }
      const index = notes.findIndex(note => note.id === id);
      if (index === -1) return { content: [{ type: "text", text: "note not found" }], isError: true };
      const [removed] = notes.splice(index, 1);
      return { content: [{ type: "text", text: `deleted ${removed.id}` }] };
    }
  );
  return server;
}

const handler = createMcpHandler(buildServer);
const resourceMetadataUrl = getOAuthProtectedResourceMetadataUrl(mcpUrl);
const app = createMcpExpressApp({ host, allowedHosts: [mcpUrl.hostname], jsonLimit: "64kb" });
app.use(mcpAuthMetadataRouter({ oauthMetadata, resourceServerUrl: mcpUrl, scopesSupported: scopes, resourceName: "Notes MCP" }));
const auth = requireBearerAuth({ verifier, requiredScopes: ["mcp"], resourceMetadataUrl });
const node = toNodeHandler(handler);
app.all(mcpUrl.pathname, auth, (req: Request, res: Response) => {
  void node(req, res, req.body);
});
const port = mcpUrl.port ? Number(mcpUrl.port) : Number(process.env.PORT ?? 3000);
app.listen(port, host);

يحمي النطاق mcp نقطة النهاية. وتوفر فحوص الأداتين فصلاً أدق. يستطيع العميل الذي يملك mcp فقط اكتشاف النقطة، لكنه يتلقى نتيجة أداة عادية مع isError: true عند استدعاء عملية محمية. يبقى الرفض مرئياً للنموذج. إذا احتاجت نقطة النهاية كلها إلى نطاق، ضعه في requiredScopes. عندها يعيد middleware الحالة 403 مع insufficient_scope.

ينشر metadata router مستند Protected Resource Metadata وفق RFC 9728 ويعكس metadata الخاص بـauthorization server وفق RFC 8414. يستطيع العميل اتباع رابط resource_metadata في تحدي WWW-Authenticate، واكتشاف issuer وendpoints، والحصول على token، ثم إعادة الطلب. لا ينفذ TypeScript SDK مزود الهوية الخاص بك. استخدم في خدمة جديدة identity provider أو OAuth server مدعوماً. أما helpers الخاصة بـauthorization server في v1 داخل @modelcontextprotocol/server-legacy/auth فهي مجمدة لأغراض الترحيل وليست الاختيار المفضل للكود الجديد.

تقوي قواعد التفويض في 2026-07-28 جانب العميل أيضاً. ينبغي لـauthorization server إعادة iss في ردود التفويض. ويقارنه العميل مع issuer المسجل أثناء discovery قبل إرسال code إلى token endpoint. يجب تقسيم client credentials والرموز بحسب issuer. وتُفضّل Client ID Metadata Documents على Dynamic Client Registration، التي بقيت للتوافق. تمنع هذه القواعد أن يحول mix-up في authorization server code أو token خادماً إلى credential لخادم آخر.

اجعل انعدام الحالة واضحاً

تنشئ factory خادماً لكل طلب، لذلك لا تضع caller المصادق عليه أو مرحلة workflow أو قرار الصلاحية في instance وتتوقع بقاءه في POST التالي. يمكن أن يصل الطلب الحديث إلى أي replica خلف round-robin load balancer عادي. خزّن حالة العمل الدائمة في قاعدة أو queue، ومرّر handle واضحاً وقصير العمر ومتحققاً منه كوسيط للأداة.

في مسار التأكيد، أعد input_required واحمِ حالته. يصدّر SDK الدوال inputRequired وacceptedContent وcreateRequestStateCodec لهذا النمط:

import { acceptedContent, createRequestStateCodec, inputRequired, McpServer } from "@modelcontextprotocol/server";
import * as z from "zod/v4";

function buildServerWithConfirmation(): McpServer {
  const key = process.env.REQUEST_STATE_KEY;
  if (!key) throw new Error("Missing REQUEST_STATE_KEY");
  type DeleteState = { noteId: string; step: "confirm" };
  const stateCodec = createRequestStateCodec<DeleteState>({
    key,
    ttlSeconds: 300,
    bind: ctx => `${ctx.mcpReq.method}\u0000${ctx.http?.authInfo?.clientId ?? ""}`
  });
  const server = new McpServer(
    { name: "notes-confirmation", version: "1.0.0" },
    { requestState: { verify: stateCodec.verify } }
  );
  const notes = [{ id: "1", text: "Rotate the signing key after the release." }];
  const confirmationSchema = z.object({ confirm: z.boolean() });
  server.registerTool(
    "notes.delete",
    { inputSchema: z.object({ id: z.string().regex(/^\d+$/) }) },
    async ({ id }, ctx) => {
      const confirmed = acceptedContent(ctx.mcpReq.inputResponses, "confirm", confirmationSchema);
      if (!confirmed) {
        return inputRequired({
          inputRequests: {
            confirm: inputRequired.elicit({
              message: "Delete this note?",
              requestedSchema: confirmationSchema
            })
          },
          requestState: await stateCodec.mint({ noteId: id, step: "confirm" }, ctx)
        });
      }
      if (!confirmed.confirm) return { content: [{ type: "text", text: "deletion declined" }], isError: true };
      const index = notes.findIndex(note => note.id === id);
      if (index === -1) return { content: [{ type: "text", text: "note not found" }], isError: true };
      notes.splice(index, 1);
      return { content: [{ type: "text", text: `deleted ${id}` }] };
    }
  );
  return server;
}

اربط stateCodec.verify بخيار requestState.verify الذي تمرره إلى McpServer. الحالة موقعة وليست مشفرة، ويعامل SDK الحالة التي يعيدها العميل على أنها غير موثوقة حتى ينفذ ذلك hook. اربطها بالـprincipal والعملية، وضع وقت انتهاء، ولا تضع أسراراً في payload. يجب أن تشترك كل replica يمكنها استقبال retry في مفتاح HMAC نفسه.

تحقّق وحدّد المعدل وانشر

تحقق Zod هو أول حد للتطبيق، لكنه ليس نموذج الأمان كله. حدّد أطوال السلاسل والمصفوفات والأرقام والمعرّفات وعناوين URL والمسارات وأحجام النتائج. تحقق مرة أخرى عند حدود downstream API، لأن schema يثبت الشكل لا الصلاحية ولا سلامة الأثر الجانبي. لا تسمح للنموذج باختيار HTTP methods أو hosts أو filesystem paths أو SQL fragments أو shell arguments بشكل حر. استخدم allowlists وواجهات مبرمجة ذات معاملات.

ينبغي أن يستخدم rate limiting قيمة clientId الموثقة أو tenant كمفتاح أساسي، مع حدود منفصلة لفشل المصادقة وdiscovery والأدوات المكلفة وعدد العمليات المتزامنة. أعد 429 مع Retry-After عند edge أو gateway. يناسب العداد المحلي عملية تطوير واحدة، لكنه ليس حداً مشتركاً للعنقود، وقد تسبب map غير المحدودة ضغطاً على الذاكرة. استخدم limiter مشتركاً مثل Redis أو سياسة gateway عند تعدد replicas. أدرج method وtool name في المقاييس من الرؤوس المتحققة، ولا تسجل bearer token أبداً.

أنهِ TLS عند النقطة العامة أو استخدم TLS من طرف إلى طرف، وحافظ على تدفق SSE، وعطّل buffering في proxy باستخدام X-Accel-Buffering: no إذا كان proxy يدعمه. اضبط مهلات الجسم والرؤوس والخمول وupstream بوضوح. اربط الخادم المحلي بـ127.0.0.1 لا بـ0.0.0.0 إلا إذا حماه allowlist وauthentication. تفعّل Express factory حماية host وorigin فقط لفئة localhost المكوّنة. عند public bind حدّد allowedHosts وallowedOrigins عن قصد.

لا يحتاج البروتوكول الحديث إلى sticky sessions. يختلف تدفق subscriptions/listen: إذا لزم توزيع إشعارات التغيير بين replicas، وفّر ServerEventBus مشتركاً فوق نظام pub/sub. أبقِ pools وقواعد البيانات وcaches على مستوى الوحدة، لكن اترك قرارات caller في request context. عند graceful shutdown استدعِ handler.close() حتى تتوقف التبادلات الحديثة الجارية قبل خروج العملية.

اختبر السلك لا الدالة فقط

يوثق SDK v2 مسار اختبار in-process يستدعي createMcpHandler عبر StreamableHTTPClientTransport مع دالة fetch مخصصة. استخدم مسار request/response الحقيقي لتغطية version negotiation والرؤوس المطلوبة وردود JSON وSSE وتمرير المصادقة وفشل schema والإلغاء وعمليات retry الخاصة بـinput_required. أضف اختبارات منفصلة لـ401 دون token، و401 لـinactive token، و403 عند فقدان endpoint scope، ورفض isError داخل النتيجة عند فقدان scope الأداة.

استخدم mode: { pin: "2026-07-28" } عندما يكون الاختبار خاصاً بالسلك الحديث. وفي اختبار التوافق استخدم mode: "auto" وتحقق من legacy fallback مقابل fixture قديمة مقصودة. أرسل طلبين من caller مختلفين وتأكد من عدم تسرب رؤية الأدوات والتفويض بينهما. في اختبار متعدد العقد أرسل retry إلى عملية أخرى وتحقق من أن state codec المشترك والتخزين الدائم يحافظان على workflow.

شخّص من أول طبقة تفشل

يعني HTTP 400 مع HeaderMismatch أن protocol header وclaim في _meta مختلفان أو أن routing header مطلوباً مفقود. أصلح العميل أو gateway ولا تنتقل بصمت إلى بروتوكول قديم عندما يكون الجسم خطأ حديثاً معروفاً. يعني HTTP 415 أن media type الخاص بـPOST ليس application/json. فقيمة مثل text/plain; a=application/json غير صالحة. و404 لـGET متوقع على endpoint الحديث. أما 404 لـPOST فقد يعني method غير معروفة أو أن المسار لم يصل إلى MCP handler.

ينبغي أن يعرض 401 تحدي WWW-Authenticate. افحص metadata URL وissuer الخاص بالtoken وaudience أو resource والتوقيع أو introspection وقيمة expiration الرقمية. يرفض bearer gate في SDK قيمة AuthInfo التي تفتقد expiresAt. يشير 403 insufficient_scope إلى scope challenge على مستوى endpoint وقد يبدأ step-up flow لدى العميل. أما رفض الأداة مع isError: true فهو نتيجة تطبيق ولا يمنح token أوسع تلقائياً.

إذا علق العميل، افحص URL وbuffering الخاص بـSSE في reverse proxy وidle timeout والرد الذي يحمل JSON-RPC ID نفسه. إذا رأى modern client خادماً legacy، فتحقق من أن versionNegotiation هو auto أو pin، وأن إصدارات SDK متطابقة، وأن gateway لم يحذف _meta أو الرؤوس المطلوبة. إذا تكرر التأكيد بلا نهاية، تحقق من مفتاح embedded response، وتحقق من inputResponses، وحدد عدد rounds، وافحص مرحلة state الموقعة. لسياق الترحيل ومصفوفة التوافق اقرأ دليل ترحيل MCP 2026-07-28. ولمخاطر injection وtool poisoning وauthorization حول MCP اقرأ حقن التعليمات وأمن MCP. ولحدود الخدمات والصفوف وعزل الأعطال استخدم بنية وكيل الذكاء الاصطناعي في الإنتاج.

المصادر

مقالات أخرى