الكتابات/tutorial/2026/08
Tutorial1 أغسطس 2026·30 دقيقة

تطبيقات MCP: بناء واجهات تفاعلية لخادم MCP باستخدام TypeScript

شرح كامل لـ MCP Apps (المعيار SEP-1865)، الامتداد الرسمي الذي يتيح لخوادم MCP تقديم واجهات تفاعلية. سنبني لوحة إيرادات تُعرض داخل Claude وChatGPT: تسجيل الأداة ومورد الواجهة، وجسر JSON-RPC عبر postMessage، والأدوات الخاصة بالتطبيق، وتنسيق المضيف، وإعلانات CSP، وتحديث سياق النموذج.

خادم MCP لديك لا يجيد سوى النص

بنيت خادم MCP. يستعلم من قاعدة بياناتك، ويستدعي واجهة الفوترة، ويسحب الأرقام من مستودع البيانات. وهو يعمل. ثم يطلب المستخدم من المساعد إيرادات الربع الماضي حسب الجهة، فتُعيد أداتك المصمَّمة بعناية جدارًا من خلايا جدول markdown، يعيد النموذج تلخيصه — بصورة رديئة — في فقرة واحدة.

بعض البيانات لا تريد أن تكون فقرة. خريطة حرارية للاحتفاظ بالعملاء لا تريد أن تكون فقرة. الخرائط، ومنزلقات الميزانية، وصفحات PDF، ومخططات الجلوس، والمشاهد ثلاثية الأبعاد — كلها تريد بكسلات وفأرة، لا رموزًا نصية.

هذه الفجوة هي ما تسدّه MCP Apps. اعتُمدت باسم SEP-1865، واستقرّت منذ مراجعة المواصفة 2026-01-26، وهي أول امتداد رسمي لبروتوكول سياق النموذج، صمّمه بالاشتراك مجتمع MCP-UI وAnthropic وOpenAI. تتيح لخادمك تقديم واجهة HTML مرافقة للأداة، وتتيح لتلك الواجهة أن تخاطب خادمك عبر بروتوكول JSON-RPC نفسه الذي يستعمله MCP أصلًا. وتعرضها اليوم Claude وChatGPT وVS Code وGoose وPostman.

في هذا الدرس نبنيها من أولها إلى آخرها.

ما الذي ستبنيه

تطبيق MCP للوحة إيرادات: أداة باسم get-revenue تُعيد أرقام الربع وتعرضها كواجهة React تفاعلية داخل المضيف، مع:

  • مخطط ومرشّح جهات قابل للنقر، دون أي رحلة ذهاب وإياب إلى النموذج
  • زر تحديث يستدعي خادم MCP مباشرةً من الواجهة
  • أداة خاصة بالتطبيق لا يراها النموذج ولا يستطيع استدعاءها، مخصّصة للإجراءات التي تنطلق من الواجهة
  • تنسيق تلقائي يجعل الواجهة تطابق الوضع الفاتح أو الداكن وخطوط المضيف
  • سياسة أمان محتوى معلنة، لأن المضيف يعزلك في صندوق رمل افتراضيًّا
  • استدعاء ui/update-model-context ليعرف المساعد ما الذي ينظر إليه المستخدم
  • حالة عرض تصمد حين يبتعد المستخدم ثم يعود

في النهاية سيكون لديك نحو 250 سطرًا من كود التطبيق، وتصوّر دقيق لحدود الثقة بين المضيف والواجهة والخادم.

المتطلبات المسبقة

قبل البدء تأكّد من توفّر:

  • Node.js 20+ ومدير حزم (نستعمل هنا pnpm، وnpm يعمل بالطريقة نفسها)
  • خبرة ببناء خادم MCP — ينبغي أن تكون الأدوات والموارد مألوفة لديك. إن لم تكن كذلك فابدأ بدليل بناء خادم MCP باستخدام TypeScript
  • إتقان عملي لـ React وTypeScript — الواجهة تطبيق React عادي، لكن حزمه يجري بطريقة غير معتادة
  • مضيف MCP يدعم امتداد Apps للاختبار النهائي: Claude Desktop أو VS Code أو المضيف المرجعي المرفق في مستودع ext-apps

لست بحاجة إلى معرفة عميقة بالـ iframe. تتكفّل حزمة التطوير بطبقة النقل؛ أما ما يجب أن تفهمه فعلًا فهو التبعات الأمنية، وهي مشروحة صراحةً في هذا الدرس.

الخطوة 1: التصوّر الذهني

تطبيق MCP هو عنصران أساسيان تعرفهما مسبقًا، يربط بينهما حقل بيانات وصفية واحد.

  1. مورد يبدأ معرّفه بـ ui:// ونوع محتواه text/html;profile=mcp-app. محتواه مستند HTML5 كامل قائم بذاته — أي واجهتك بعد حزمها.
  2. أداة يشير حقل _meta.ui.resourceUri فيها إلى معرّف ذلك المورد.

حين يستدعي النموذج الأداة على مضيف يدعم الامتداد، يقوم المضيف بثلاثة أمور: يقرأ المورد المشار إليه، ويعرض HTML داخل iframe معزول، ثم يسلّم نتيجة الأداة إلى الواجهة العاملة. عندئذ تتصرف الواجهة كعميل MCP: تستطيع استدعاء tools/call وresources/read، ويمرّر المضيف هذه الطلبات إلى خادمك.

دورة الحياة بالترتيب:

الخطوةالفاعلالرسالة
1النموذجtools/call لأداة get-revenue
2المضيفresources/read على ui://revenue/dashboard.html
3المضيفيعرض HTML داخل الصندوق المعزول
4الواجهةطلب ui/initialize إلى المضيف
5المضيفيُعيد hostContext — السمة واللغة وأبعاد الحاوية
6الواجهةui/notifications/initialized
7المضيفيسلّم نتيجة الأداة إلى الواجهة
8الواجهةتستدعي tools/call وui/update-model-context مع تفاعل المستخدم

قراران تصميميان هنا يستحقان وقفة. القوالب مُعلَنة مسبقًا، فيستطيع المضيف فحص كل واجهة قد يعرضها الخادم وجلبها مسبقًا قبل تشغيل أي أداة. وكل رسالة بين الواجهة والمضيف هي JSON-RPC، أي قابلة للتسجيل والتدقيق — فلا شيء يجري عبر قناة جانبية معتمة.

يُعرَّف الامتداد باسم io.modelcontextprotocol/ui، ويجري التفاوض عليه عند إنشاء الاتصال. والمضيف الذي لا يدعمه يكتفي بالمحتوى النصي لأداتك، ما يعني أن خادمًا مدركًا لـ Apps يتدهور بلطف على العملاء الأقدم.

الخطوة 2: تهيئة المشروع

أنشئ المشروع وثبّت الاعتمادات:

mkdir revenue-app && cd revenue-app
pnpm init
pnpm pkg set type=module
 
pnpm add @modelcontextprotocol/ext-apps @modelcontextprotocol/sdk express cors react react-dom
pnpm add -D typescript vite vite-plugin-singlefile @vitejs/plugin-react \
  @types/express @types/cors @types/node @types/react @types/react-dom \
  tsx concurrently cross-env

الاعتماد غير المألوف هنا هو vite-plugin-singlefile. يجب أن تُسلَّم واجهتك بوصفها مستند HTML واحدًا — فالمضيف يقرأ موردًا واحدًا لا مجلّد أصول. وهذه الإضافة تُدمج كل سكربت وكل ورقة أنماط داخل مخرجات HTML.

أضف سكربتات البناء:

pnpm pkg set scripts.build="tsc --noEmit && cross-env INPUT=mcp-app.html vite build"
pnpm pkg set scripts.dev='concurrently --raw "cross-env NODE_ENV=development INPUT=mcp-app.html vite build --watch" "tsx watch main.ts"'

ثم ملف vite.config.ts:

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { viteSingleFile } from "vite-plugin-singlefile";
 
const INPUT = process.env.INPUT;
if (!INPUT) throw new Error("INPUT environment variable is not set");
 
const isDevelopment = process.env.NODE_ENV === "development";
 
export default defineConfig({
  plugins: [react(), viteSingleFile()],
  build: {
    // خرائط المصدر المضمّنة في التطوير فقط — الحزمة تُنقل داخل حمولة JSON-RPC
    sourcemap: isDevelopment ? "inline" : undefined,
    cssMinify: !isDevelopment,
    minify: !isDevelopment,
    rollupOptions: { input: INPUT },
    outDir: "dist",
    emptyOutDir: false,
  },
});

أبقِ emptyOutDir: false. فمخرجات خادمك المُصرَّفة تحطّ في مجلد dist نفسه، ولا تريد لبناء الواجهة أن يمحوها في كل مرة.

الخطوة 3: تسجيل الأداة ومورد الواجهة

هذا هو جوهر الأمر. أنشئ server.ts:

import {
  registerAppResource,
  registerAppTool,
  RESOURCE_MIME_TYPE,
} from "@modelcontextprotocol/ext-apps/server";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import fs from "node:fs/promises";
import path from "node:path";
import { z } from "zod";
import { fetchRevenue } from "./data.js";
 
const DIST_DIR = path.join(import.meta.dirname, "dist");
 
// معرّف واحد يُشار إليه من التسجيلين. احتفظ به في ثابت —
// فالخطأ المطبعي هنا يفشل بصمت على هيئة "الأداة تعمل ولا واجهة تظهر".
const RESOURCE_URI = "ui://revenue/dashboard.html";
 
export function createServer(): McpServer {
  const server = new McpServer({
    name: "Revenue Dashboard",
    version: "1.0.0",
  });
 
  registerAppTool(
    server,
    "get-revenue",
    {
      title: "Get Revenue",
      description: "Show quarterly revenue broken down by region.",
      inputSchema: { quarter: z.string().describe("e.g. 2026-Q2") },
      _meta: { ui: { resourceUri: RESOURCE_URI } }, // [!code highlight]
    },
    async ({ quarter }) => {
      const rows = await fetchRevenue(quarter);
      const total = rows.reduce((sum, r) => sum + r.revenue, 0);
 
      return {
        // `content` هو ما يقرأه النموذج — أبقِه قصيرًا
        content: [
          { type: "text", text: `Revenue for ${quarter}: ${total} TND across ${rows.length} regions.` },
        ],
        // `structuredContent` هو ما تقرأه الواجهة — ضع الحمولة الكاملة هنا
        structuredContent: { quarter, rows, total },
      };
    },
  );
 
  registerAppResource(
    server,
    "revenue-dashboard",
    RESOURCE_URI,
    { mimeType: RESOURCE_MIME_TYPE },
    async () => {
      const html = await fs.readFile(path.join(DIST_DIR, "mcp-app.html"), "utf-8");
      return {
        contents: [{ uri: RESOURCE_URI, mimeType: RESOURCE_MIME_TYPE, text: html }],
      };
    },
  );
 
  return server;
}

الثابت RESOURCE_MIME_TYPE يقابل text/html;profile=mcp-app. استعمله بدل كتابة النص يدويًّا؛ فمعامل الملف الشخصي سهل الخطأ بصورة خفية.

الفصل بين content وstructuredContent هو أنفع عادة في هذا الدرس كلّه. يدخل content في نافذة سياق النموذج ويكلّفك رموزًا في كل دور. أما structuredContent فيُمرَّر إلى واجهتك كخاصية React، ويُبقيه المضيف الداعم خارج سياق النموذج. فالأداة التي تُعيد 400 صف ينبغي أن تُعيد جملة واحدة في content والمصفوفة كاملةً في structuredContent.

إن صادفت المفتاح المسطّح القديم _meta["ui/resourceUri"] في تدوينات أواخر 2025، فهو ما يزال يعمل لكنه مهجور ومقرَّر إزالته قبل الإصدار العام. استعمل الصيغة المتداخلة _meta.ui.resourceUri.

الخطوة 4: بناء الواجهة

هيكل HTML في mcp-app.html:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta name="color-scheme" content="light dark" />
    <title>Revenue Dashboard</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/mcp-app.tsx"></script>
  </body>
</html>

والآن src/mcp-app.tsx. يُنشئ الخطّاف useApp نسخة من App، ويتيح لك تسجيل المعالجات قبل الاتصال، ثم يتصل:

import type { App } from "@modelcontextprotocol/ext-apps";
import { useApp, useHostStyleVariables } from "@modelcontextprotocol/ext-apps/react";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { StrictMode, useState } from "react";
import { createRoot } from "react-dom/client";
 
interface RevenueRow {
  region: string;
  revenue: number;
}
interface RevenuePayload {
  quarter: string;
  rows: RevenueRow[];
  total: number;
}
 
function RevenueApp() {
  const [data, setData] = useState<RevenuePayload | null>(null);
 
  const { app, error } = useApp({
    appInfo: { name: "Revenue Dashboard", version: "1.0.0" },
    capabilities: {},
    onAppCreated: (app) => {
      // سجّل المعالج قبل connect() وإلا فاتتك نتيجة الأداة الأولى
      app.ontoolresult = (result: CallToolResult) => { // [!code highlight]
        setData(result.structuredContent as unknown as RevenuePayload);
      };
      app.onerror = console.error;
    },
  });
 
  // تبنّي متغيّرات CSS ونظام الألوان من المضيف
  useHostStyleVariables(app, app?.getHostContext());
 
  if (error) return <p>Failed to connect: {error.message}</p>;
  if (!app || !data) return <p>Loading…</p>;
 
  return <Dashboard app={app} data={data} onData={setData} />;
}
 
createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <RevenueApp />
  </StrictMode>,
);

التعليق الخاص بالترتيب أهم مما يبدو. فالمضيف يسلّم نتيجة الأداة فور إرسال الواجهة إشعار ui/notifications/initialized. وإن أسندت ontoolresult بعد اكتمال connect() فأنت أمام سباق زمني، سينكشف على جهاز بطيء ويمرّ على جهازك. ووجود onAppCreated غرضه إغلاق هذه النافذة تحديدًا.

تفضّل JavaScript الصِّرفة أو إطارًا آخر؟ دورة الحياة نفسها تعمل بلا React:

import { App } from "@modelcontextprotocol/ext-apps";
 
const app = new App({ name: "Revenue Dashboard", version: "1.0.0" });
app.ontoolresult = (result) => render(result.structuredContent);
app.connect();

وفي مستودع ext-apps أمثلة بدء رسمية لـ Vue وSvelte وPreact وSolid وJavaScript الصِّرفة — والشقّ الخادمي متطابق في جميعها.

الخطوة 5: دع الواجهة تردّ

الواجهة التي تعرض فقط مجرد صورة. المثير هو مسار العودة. تُرسل app.callServerTool() طلب tools/call عبر المضيف إلى خادمك وتُرجع النتيجة:

function Dashboard({ app, data, onData }: {
  app: App;
  data: RevenuePayload;
  onData: (d: RevenuePayload) => void;
}) {
  const [busy, setBusy] = useState(false);
  const [selected, setSelected] = useState<string | null>(null);
 
  async function refresh() {
    setBusy(true);
    try {
      const result = await app.callServerTool({
        name: "get-revenue",
        arguments: { quarter: data.quarter },
      });
      onData(result.structuredContent as unknown as RevenuePayload);
    } catch (e) {
      console.error("Refresh failed", e);
    } finally {
      setBusy(false);
    }
  }
 
  const rows = selected ? data.rows.filter((r) => r.region === selected) : data.rows;
  const max = Math.max(...data.rows.map((r) => r.revenue));
 
  return (
    <main style={{ fontFamily: "var(--font-sans)", color: "var(--color-text-primary)" }}>
      <header>
        <h2>{data.quarter}</h2>
        <button onClick={refresh} disabled={busy}>
          {busy ? "Refreshing…" : "Refresh"}
        </button>
      </header>
 
      {rows.map((row) => (
        <div key={row.region} onClick={() => setSelected(row.region)}>
          <span>{row.region}</span>
          <div style={{ width: `${(row.revenue / max) * 100}%` }} role="presentation" />
          <span>{row.revenue.toLocaleString()} TND</span>
        </div>
      ))}
 
      {selected && <button onClick={() => setSelected(null)}>Clear filter</button>}
    </main>
  );
}

الترشيح حسب الجهة لا يكلّف رموزًا ولا زمن استجابة، لأنه لا يغادر الـ iframe أصلًا. وهذه هي الحجة الاقتصادية كاملةً لـ MCP Apps: التفاعلات ذات الطابع العرضي لا ينبغي أن تُدفع من ميزانية الاستدلال.

يقدّم الصنف App أكثر من استدعاء الأدوات. وأكثر ما ستحتاجه:

الدالةوظيفتها
callServerTool()استدعاء أداة على خادم MCP عبر المضيف
readServerResource()قراءة أي مورد من الخادم — مفيدة للكتل الثنائية
sendMessage()إدراج رسالة في المحادثة كأن المستخدم كتبها
updateModelContext()إخبار النموذج بما تعرضه الواجهة دون دور محادثة
openLink()مطالبة المضيف بفتح رابط خارجي
sendLog()إرسال سطر سجل يستطيع المضيف عرضه للمطوّر
requestDisplayMode()طلب inline أو fullscreen أو pip

وكلٌّ من هذه قابل لأن يُرفض. فالمضيف هو حدّ الثقة، وتُرجع openLink وsendMessage نتيجة تحمل isError عند الرفض بدل رمي استثناء. تحقّق منها وتدهور بلطف — عرض الرابط الخام لنسخه يدويًّا أفضل من زرّ ميت.

الخطوة 6: الأدوات الخاصة بالتطبيق

والآن النمط الذي يفوت أكثر القرّاء في المرة الأولى. زر التحديث لديك يستدعي get-revenue، وهي أداة يستطيع النموذج استدعاءها كذلك. وكثيرًا ما تريد العكس: إجراءً لا ينبغي أن تطلقه سوى الواجهة — حفظ مرشّح، أو إقرار تنبيه، أو تصفّح نتيجة ضخمة — ولا سبب لبقائه في قائمة أدوات النموذج يستهلك رموز المخطط ويفتح باب سوء الاستعمال.

اضبط visibility: ["app"]:

registerAppTool(
  server,
  "save-dashboard-filter",
  {
    description: "Persist the user's selected region filter.",
    inputSchema: { viewId: z.string(), region: z.string().nullable() },
    _meta: {
      ui: {
        resourceUri: RESOURCE_URI,
        visibility: ["app"], // [!code highlight]
      },
    },
  },
  async ({ viewId, region }) => {
    await saveFilter(viewId, region);
    return { content: [{ type: "text", text: "ok" }] };
  },
);

يجب على المضيف الآن استبعاد هذه الأداة من tools/list كما يراها الوكيل، ورفض أي tools/call لها لا يصدر عن تطبيق على اتصال الخادم نفسه. والاستدعاءات العابرة للخوادم للأدوات الخاصة بالتطبيق محظورة دائمًا. وتكون قيمة visibility الافتراضية ["model", "app"] عند إغفالها، فالأداة العادية قابلة للاستدعاء من الجهتين.

الخطوة 7: تبنّي سمة المضيف

الواجهة التي تظهر مستطيلًا أبيض داخل محادثة داكنة في Claude تبدو معطوبة مهما كان المخطط جيدًا. أثناء ui/initialize يُعيد المضيف hostContext يتضمّن خريطة styles.variables من خصائص CSS المخصّصة، وسمة theme بقيمة light أو dark، وقواعد @font-face اختيارية، ولغة المستخدم locale ومنطقته الزمنية timeZone، والمنصّة، وsafeAreaInsets للأجهزة المحمولة.

الخطّاف useHostStyleVariables — الذي وصّلناه في الخطوة 4 — يكتب هذه المتغيّرات على document.documentElement ويضبط color-scheme كي تُحلّ دالة CSS المسماة light-dark() تحليلًا صحيحًا. وبعد ذلك نسّق اعتمادًا على المتغيّرات:

:root {
  color-scheme: light dark;
}
 
main {
  background: var(--color-background-primary, #fff);
  color: var(--color-text-primary, #171717);
  font-family: var(--font-sans, system-ui, sans-serif);
}

قدّم دائمًا قيمة احتياطية داخل var(). فالمضيفون غير ملزمين بإرسال كل متغيّر، والمتغيّر المفقود بلا احتياطي يُعرض نصًّا شفافًا على خلفية شفافة.

وللمضيفين الناطقين بالعربية اقرأ hostContext.locale واضبط الاتجاه صراحةً — فالمضيف لن يفعلها نيابةً عنك:

const locale = app.getHostContext()?.locale ?? "en";
const dir = locale.startsWith("ar") ? "rtl" : "ltr";
// ثم: <main dir={dir}>

وسياق المضيف ليس ثابتًا. سجّل app.onhostcontextchanged للاستجابة حين يبدّل المستخدم الوضع الداكن أو يغيّر حجم النافذة أثناء الجلسة.

الخطوة 8: أعلن سياسة أمان المحتوى

هنا يلتقي البناء المحلي الناجح باللوحة الفارغة في الإنتاج. يعزل المضيف واجهتك، وإن لم تُعلن شيئًا طبّق سياسة افتراضية عدائية عن قصد:

default-src 'none';
script-src 'self' 'unsafe-inline';
style-src 'self' 'unsafe-inline';
img-src 'self' data:;
media-src 'self' data:;
connect-src 'none';

اقرأ connect-src 'none' بتمعّن: لا تستطيع واجهتك إجراء أي طلب شبكي إطلاقًا افتراضيًّا. لا fetch إلى واجهتك، ولا مكتبة مخططات من شبكة توزيع، ولا صورة بعيدة. وهذا مقصود — فالخادم الخبيث ينبغي ألّا يستطيع تسريب محادثة عبر عنوان صورة.

كل ما هو خارجي يجب إعلانه في _meta.ui الخاص بالمورد:

registerAppResource(
  server,
  "revenue-dashboard",
  RESOURCE_URI,
  { mimeType: RESOURCE_MIME_TYPE },
  async () => ({
    contents: [{
      uri: RESOURCE_URI,
      mimeType: RESOURCE_MIME_TYPE,
      text: html,
      _meta: {
        ui: {
          csp: {
            connectDomains: ["https://api.example.com"],
            resourceDomains: ["https://cdn.jsdelivr.net", "https://*.example-cdn.com"],
          },
          permissions: { clipboardWrite: {} },
          prefersBorder: true,
        },
      },
    }],
  }),
);

ثلاث قواعد ينبغي استيعابها. المضيفون يجوز لهم التشديد ولا يجوز لهم التخفيف لما أعلنته، فالنطاق الذي نسيته نطاق لن تحصل عليه. والصلاحيات permissions — الكاميرا والميكروفون والموقع والكتابة في الحافظة — هي طلبات لا منح؛ افحص توفّرها في زمن التشغيل وتدهور بلطف. وينبغي ضبط prefersBorder صراحةً، لأن الافتراضات تختلف بين المضيفين، وترك القيمة دون تحديد يعني أن بطاقتك قد تحصل على إطار وقد لا تحصل.

وأقصر الطرق أمانًا ألّا تكون لك اعتمادات خارجية أصلًا: احزم كل شيء عبر viteSingleFile، ومرّر كل البيانات عبر structuredContent، ولا تُعلن أي نطاق. عندها تعمل واجهتك تحت أشدّ سياسة ممكنة وتشتغل في كل مضيف دون تفاوض.

الخطوة 9: أبقِ النموذج على اطّلاع

يرشّح المستخدم النتائج إلى صفاقس، ثم يكتب: «لماذا انخفضت هذه؟» — والنموذج لا يدري ما «هذه»، لأن الترشيح جرى داخل iframe لا يراه.

وهذا بالضبط ما تحلّه updateModelContext. فهي تدفع تحديثًا للسياق دون إنشاء دور محادثة مرئي:

async function selectRegion(region: string) {
  setSelected(region);
 
  const row = data.rows.find((r) => r.region === region);
  const markdown = `---
selected-region: ${region}
revenue: ${row?.revenue ?? 0}
quarter: ${data.quarter}
---
 
The user is now viewing the ${region} region in the revenue dashboard.`;
 
  await app.updateModelContext({ content: [{ type: "text", text: markdown }] });
}

وترويسة YAML هي العرف الذي تتبعه أمثلة المواصفة نفسها — تُحلَّل بموثوقية وتُقرأ جيدًا داخل نافذة السياق. والدالة ذاتها هي الطريق الصحيح للإبلاغ عن واجهة متدهورة: إن رُفض getUserMedia أو فشل تحميل مكتبة مخططات، أخبر النموذج بأن هذه القدرة غير متاحة كي يكفّ عن اقتراح زرّ لن يعمل.

لكن استعملها بوعي. فكل تحديث يستهلك سياقًا. أطلقها عند تغيّرات الحالة ذات المعنى، لا مع كل حركة فأرة.

الخطوة 10: الأبعاد والاستمرارية وأوضاع العرض

الأبعاد. يخبرك hostContext.containerDimensions بالمحاور التي تتحكّم بها. وجود حقل height يعني أن المضيف ثبّته وعلى واجهتك ملؤه بـ 100vh. ووجود maxHeight يعني أنك تتحكّم بالارتفاع حتى ذلك الحد. وغيابهما معًا يعني بلا حدّ. ومع الأبعاد المرنة تُرسل حزمة التطوير إشعار ui/notifications/size-changed تلقائيًّا عبر ResizeObserver مع كبح للتكرار — وخيار autoResize مفعّل افتراضيًّا، فالأمر يعمل من تلقاء نفسه عمليًّا.

الاستمرارية. للحالة القابلة للاستعادة — موضع تمرير، أو جهة مختارة، أو زاوية كاميرا خريطة — اجعل الأداة تُعيد معرّفًا ثابتًا واستعمله مفتاحًا في localStorage:

// على الخادم، داخل معالج الأداة
return {
  content: [{ type: "text", text: summary }],
  structuredContent: payload,
  _meta: { viewUUID: randomUUID() },
};
// في الواجهة
app.ontoolresult = (result) => {
  const viewUUID = result._meta?.viewUUID ? String(result._meta.viewUUID) : undefined;
  const saved = viewUUID ? localStorage.getItem(viewUUID) : null;
  if (saved) restore(JSON.parse(saved));
};

أما الحالة التي تمثّل جهدًا حقيقيًّا للمستخدم — تعليقات، أو إعدادات محفوظة، أو ملفات مرفوعة — فلا تستعمل لها localStorage. احفظها على الخادم عبر أداة خاصة بالتطبيق، منسوبة إلى معرّف العرض ذاته.

أوضاع العرض. أعلن ما تدعمه في appCapabilities.availableDisplayModes أثناء التهيئة، ثم استدعِ app.requestDisplayMode() حين ينقر المستخدم زر التوسيع. اللوحة ينبغي أن تعلن ["inline", "fullscreen"]؛ ومشغّل الفيديو قد يضيف pip. ولا تعرض عنصر التحكم إلا إذا أفاد hostContext.availableDisplayModes بأن المضيف يستطيع تلبيته.

اختبار ما بنيته

ابنِ الحزمة وشغّل الخادم:

pnpm build
pnpm dev
# MCP server listening on http://localhost:3001/mcp

أسرع حلقة تغذية راجعة هي المضيف المرجعي في مستودع ext-apps، فهو يعرض واجهتك في متصفّح مع أدوات مطوّر كاملة:

git clone https://github.com/modelcontextprotocol/ext-apps.git
cd ext-apps && npm install
cd examples/basic-host && npm start
# افتح http://localhost:8080

اختر get-revenue من قائمة الأدوات، وانقر Call Tool، فتُعرض لوحتك في الصندوق المعزول أسفل الصفحة. ولأنه iframe حقيقي في متصفّح حقيقي، تعمل console.log ونقاط التوقّف كالمعتاد.

ولاختبارها في مساعد فعلي عبر stdio، وجّه العميل إلى نقطة الدخول المبنية:

{
  "mcpServers": {
    "revenue": {
      "command": "node",
      "args": ["/absolute/path/to/revenue-app/dist/index.js", "--stdio"]
    }
  }
}

ثم اطلب من المساعد إيرادات ربع معيّن وراقب انطلاق الأداة.

حل المشكلات الشائعة

الأداة تعمل ولا تظهر واجهة. لا يطابق resourceUri في _meta أي مورد مسجَّل، أو أن المضيف لا يدعم امتداد Apps. تحقّق من تطابق النص حرفيًّا أولًا — فهذا أشيع أسباب الفشل، وهو صامت بحكم التصميم لأن التراجع إلى النص هو السلوك المنصوص عليه.

الواجهة تظهر فارغة. حزمتك ليست ملفًّا واحدًا. تأكّد أن dist/mcp-app.html يحتوي محتوى السكربتات مضمّنًا لا سمات src تشير إلى ملفات مجاورة. فمراجع الأصول الخارجية تُحلّ مقابل أصل الصندوق المعزول لا خادمك، ومن ثم تفشل.

الواجهة تُحمَّل ولا تصلها بيانات. أُسندت ontoolresult بعد connect(). انقلها إلى onAppCreated.

طلبات الشبكة تفشل بخطأ CSP. الأصل غير مدرج في connectDomains. وتذكّر أن النطاقات الفرعية تحتاج رمزًا شاملًا صريحًا: https://*.example.com.

الخطوط والألوان تبدو خاطئة. لم تستدعِ useHostStyleVariables، أو نسّقت بقيم مثبّتة بدل var(--color-*).

كل شيء يعمل محليًّا ويتعطّل في مضيف بعينه. تحقّق مما إذا كان ذلك المضيف يشترط أصل صندوق معزول مخصّصًا عبر _meta.ui.domain. فـ Claude وChatGPT يستعملان صيغتين مختلفتين — نطاقات فرعية قائمة على البصمة وأخرى مشتقّة من الرابط — وهذا معتمد على المضيف صراحةً في المواصفة.

ملاحظات أمنية تستحق قراءتين

صُمّمت MCP Apps بنموذج تهديد واضح، والبناء الصحيح عليها يقتضي فهم ما الذي يحمي من.

تعمل واجهتك في iframe على أصل مختلف عن المضيف، ملفوفة بوسيط صندوق معزول بصلاحيتَي allow-scripts وallow-same-origin فقط. لا تستطيع قراءة المحادثة، ولا لمس DOM المضيف، ولا الوصول إلى الشبكة خارج النطاقات التي أعلنتها. وكل ما تطلبه يمرّ عبر JSON-RPC قابل للتدقيق، وللمضيفين أن يشترطوا موافقة صريحة من المستخدم قبل تمرير استدعاء أداة. وإعلان القوالب مسبقًا يعني أن المضيف يستطيع مراجعة كل واجهة قد يعرضها الخادم عند إنشاء الاتصال لا عند التنفيذ.

وما لا يحمي منه ذلك هو خادم مخترَق أو مؤلّف خادم خبيث — أي أنت. فواجهتك تعمل بصلاحيات خادمك وتستطيع استدعاء أدواتك. تحقّق من المعطيات على الخادم تمامًا كما تفعل مع أداة يستدعيها النموذج؛ فالطلب الوارد من واجهتك أنت ليس أجدر بالثقة من الوارد من النموذج. ولا تضمّن أسرارًا في حزمة HTML أبدًا: فالمستند كلّه مقروء لكل من يستطيع استدعاء resources/read.

الخطوات التالية

  • أضف مكتبة مخططات حقيقية، لكن افحص حجم الحزمة أولًا — فمستند HTML كاملًا ينتقل داخل رسالة JSON-RPC في كل عرض
  • استكشف خوادم الأمثلة الرسمية: كرة أرضية بـ CesiumJS، ومشهد Three.js، ومصيّر تظليل حيّ، وخريطة حرارية للأفواج، وعارض PDF بتحميل مجزّأ
  • تنقل تطبيق ChatGPT قائمًا؟ يقابل _meta["openai/outputTemplate"] في Apps SDK حقلَ _meta.ui.resourceUri، والمستودع يوفّر مهارة وكيل تُجري التحويل
  • اقرن هذا الدرس بـ خوادم MCP عديمة الحالة إن نشرت على بنية بلا خوادم، فالواجهة قد تعمّر أطول من طلب واحد
  • تريد توصيل أدوات في المتصفّح بدل واجهة من الخادم؟ WebMCP يحلّ المسألة المعاكسة

الخلاصة

تأخذ MCP Apps العنصرين اللذين تعرفهما مسبقًا — الأدوات والموارد — وتضيف حقل بيانات وصفية واحدًا فقط ليربط بينهما. وهذا الاقتصاد في التصميم هو سبب حلولها أولَ امتداد رسمي للبروتوكول بدل أن تكون معيارًا منافسًا: لا شيء في خادمك القائم يتغيّر، والمضيفون بلا دعم يتراجعون إلى النص تلقائيًّا.

والعادات المهمة صغيرة ومحدّدة. أبقِ content قصيرًا وضع الحمولة في structuredContent. سجّل المعالجات في onAppCreated لا بعد الاتصال. أعلن كل أصل خارجي، أو الأفضل ألّا تُعلن شيئًا. نسّق بمتغيّرات المضيف. وأخبر النموذج بما يفعله المستخدم عبر updateModelContext بدل أن تراهن على تخمينه.

اضبط هذه، فيكفّ خادمك عن سرد البيانات ويبدأ بعرضها.