اگر سرور MCP شما داخل ابزارش از کاربر می‌پرسد، روی نسخه‌ی 2026-07-28 پروتکل این کار دیگر انجام نمی‌شود: سرور دیگر نمی‌تواند درخواستی به کلاینت بفرستد و فراخوانی با خطای NoBackChannelError می‌میرد. در این نوشته با mcp نسخه‌ی 2.2.0 و 2.3.0 روی پایتون 3.14.7 اندازه گرفتم که یک سرور واقعی چطور از کلاینت تأیید می‌گیرد، کدام خط فرمان آن را روشن می‌کند، و چرا طرح‌واره‌ی پرسش باید تخت باشد. لحظه‌ی خواندن داده: ۱۷ مهر ۱۴۰۵، برابر با ۹ اکتبر ۲۰۲۶.

سرور از کلاینت می‌پرسد، نه برعکس

تا پیش از نسخه‌ی 2025-06-18 پروتکل، سرور MCP می‌توانست در هر لحظه از کلاینت بپرسد و منتظر جواب بماند. روشن‌ترین نمونه‌اش elicitation/create است: سرور یک طرح‌واره می‌فرستد و کلاینت فرم را به کاربر نشان می‌دهد. مشخصات این نسل مستقیم می‌گوید که سرور نباید برای اطلاعات حساس از این راه استفاده کند، چون داده از کانتکست کلاینت رد می‌شود.

نسل بعدی این سازوکار را برداشت. بند ۷ فهرست تغییرات نسخه‌ی 2026-07-28 می‌گوید الگوی چند رفت‌وبرگشتی جای درخواست‌های آغازشده از سمت سرور را گرفته است، و صفحه‌ی الگوی چند رفت‌وبرگشتی این را یک تغییر ناسازگار اعلام می‌کند. بند ۱۱ همان فهرست می‌گوید اعلان notifications/elicitation/complete و فیلد elicitationId هم حذف شده‌اند.

نتیجه‌ی عملی روی همین نسخه‌ها اندازه گرفته شد: یک سرور روی stdio که داخل ابزارش ctx.elicit را صدا می‌زند، در حالت پیش‌فرض چنین خطایی می‌دهد.

$ python3 deploy_client.py auto staging
protocol_version: 2026-07-28
server capabilities: {"prompts": {"list_changed": true}, "resources": {"subscribe": true, "list_changed": true}, "tools": {"list_changed": true}}
mcp.shared.exceptions.MCPError: Cannot send 'elicitation/create': this transport
context has no back-channel for server-initiated requests.

کد خطای این خطا INVALID_REQUEST است و خود کتابخانه آن را از یک پرچم به نام can_send_request می‌خواند. توضیح همان پرچم در بسته‌ی mcp دقیق است: در هر اتصال 2026-07-28 این پرچم خاموش است و دیسپچر ارزش آن را عمدا پایین می‌آورد. پس خطا نشانه‌ی بد بودن کد شما نیست، نتیجه‌ی یک تصمیم پروتکل است.

راه‌حل: برگشت به نسل قبلی

کلاینت یک پارامتر mode دارد که نسخه‌ی پروتکل را تعیین می‌کند و مقدار legacy همان دست‌دادن قدیمی را اجبار می‌کند. با همین یک کلمه، پرسش سرور کار می‌کند.

# نصب کتابخانه در یک محیط جداگانه تا نسخه‌ی نصب‌شده‌ی اصلی نشکند
$ uv venv _venv_mrtr --python 3.14
$ uv pip install --python _venv_mrtr/bin/python "mcp==2.3.0"

# حالت مذاکره‌شده با legacy: سرور می‌تواند بپرسد
$ python3 deploy_client.py legacy staging
protocol_version: 2025-11-25
  [client] پیام سرور: سرویس billing روی کدام محیط مستقر شود؟
  [client] طرح‌واره: {"properties": {"environment": {"description": "محیط مقصد: staging یا production", "enum": ["staging", "production"], "title": "Environment", "type": "string"}, "approve": {"description": "آیا کاربر تأیید نهایی می‌دهد؟", "title": "Approve", "type": "boolean"}}, "required": ["environment", "approve"], "title": "DeployTarget", "type": "object"}
deploy -> billing -> staging (approve=True)

# همان کد در حالت auto: همان پروتکل جدید، همان خطا
$ python3 deploy_client.py auto staging
protocol_version: 2026-07-28
mcp.shared.exceptions.MCPError: Cannot send 'elicitation/create': this transport
context has no back-channel for server-initiated requests.

تفاوت دو خروجی فقط در یک کلمه است: شماره‌ی نسخه‌ی پروتکل از 2025-11-25 به 2026-07-28 می‌رود و پرسش از کار درمی‌آید. همین اندازه‌گیری را روی 2.3.0 که آخرین نسخه‌ی روی PyPI در زمان خواندن این متن بود تکرار کردم و نتیجه یکی بود، پس مسئله به نسخه‌ی 2.2.0 وابسته نیست.

حالت اتصالنسخه‌ی پروتکلنتیجه‌ی ctx.elicit
mode="legacy"2025-11-25پرسش به کلاینت می‌رسد و جواب برمی‌گردد
mode="auto"2026-07-28NoBackChannelError و پایان فراخوانی

هر سه بخش جدول از همان دو اجرای بالا خوانده شده‌اند: شماره‌ی نسخه از خط protocol_version و نتیجه از خط آخر هر اجرا. همین مقایسه روی mcp نسخه‌ی 2.2.0 و 2.3.0 یکسان ماند.

سه پاسخ ممکن است، و کدام را باید جدی گرفت

پرسش سرور سه پاسخ دارد و دو تای اول با هم یکی نیستند. accept یعنی کاربر داده را داد و ابزار باید جلو برود. decline یعنی کاربر آگاهانه رد کرد و شما باید راه جایگزین پیشنهاد دهید. cancel یعنی کاربر پنجره را بست و هیچ تصمیمی نگرفته است. مشخصات نسل اول این سه را با همین معنی تعریف می‌کند.

from mcp import Client
from mcp.client.stdio import StdioServerParameters
from mcp.types import ElicitResult
import anyio, sys

# پاسخ کلاینت؛ یک ایجنت می‌تواند همین را از کاربر بپرسد یا خودش پر کند
async def answer(ctx, params):
    print("پیام سرور:", params.message)
    print("طرح‌واره:", params.requested_schema)
    return ElicitResult(action="accept",
                        content={"environment": "staging", "approve": True})

async def main():
    sp = StdioServerParameters(command=sys.executable, args=["deploy_server.py"])
    # legacy یعنی نسل 2025-11-25 و باز شدن کانال برگشتی برای پرسش سرور
    async with Client(sp, mode="legacy", elicitation_callback=answer) as c:
        print("نسخه‌ی پروتکل:", c.protocol_version)
        r = await c.call_tool("deploy", {"service": "billing"})
        print("نتیجه:", r.content[0].text)

anyio.run(main)

خروجی واقعی این کد در دو حالت فرق دارد. وقتی کلاینت accept می‌دهد، خط آخر billing -> staging (approve=True) است. وقتی کلاینت decline می‌دهد، سرور باید کاری نکند و همان را برگرداند.

$ python3 deploy_client.py legacy decline
protocol_version: 2025-11-25
  [client] پیام سرور: سرویس billing روی کدام محیط مستقر شود؟
deploy -> action=decline: nothing deployed

اگر در کد سرور فقط حالت accept را بنویسید و بقیه را یک شاخه‌ی خالی رها کنید، یک AttributeError روی res.data می‌گیرید، چون در حالت رد کردن اصلاً data وجود ندارد. شرط روی action باید پیش از خواندن data بیاید.

طرح‌واره‌ی پرسش باید تخت بماند

محدودیت اصلی این نسل روی خود طرح‌واره است: فقط شیء تخت با چند فیلد اولیه. نه فهرست، نه زیرمدل، نه آبجکت تودرتو. کتابخانه‌ی پایتون این قاعده را خودش اعمال می‌کند و پیش از فرستادن پیام، طرح‌واره‌ی رندرشده را با تعریف رسمی می‌سنجد.

from mcp.server.elicitation import render_elicitation_schema
from pydantic import BaseModel, Field

class Good(BaseModel):
    environment: str = Field(json_schema_extra={"enum": ["staging", "production"]})
    approve: bool

class WithList(BaseModel):
    hosts: list[str]          # فهرست، طرح‌واره‌ی اولیه نیست

class Nested(BaseModel):
    target: Good              # زیرمدل هم پذیرفته نمی‌شود

for name, model in [("Good", Good), ("WithList", WithList), ("Nested", Nested)]:
    try:
        print(name, render_elicitation_schema(model))
    except TypeError as e:
        print(name, "->", type(e).__name__, e)

خروجی واقعی این اسکریپت نشان می‌دهد کتابخانه کجا متوقف می‌شود.

Good
{"properties": {"environment": {"enum": ["staging", "production"], "title": "Environment", "type": "string"}, "approve": {"title": "Approve", "type": "boolean"}}, "required": ["environment", "approve"], "title": "Good", "type": "object"}
WithList -> TypeError Elicitation schema field 'hosts' rendered as {'items': {'type': 'string'}, 'title': 'Hosts', 'type': 'array'}, which is not a valid PrimitiveSchemaDefinition
Nested -> TypeError Elicitation schema field 'target' rendered as {'$ref': '#/$defs/Good'}, which is not a valid PrimitiveSchemaDefinition

پیام خطا نام فیلد را می‌گوید و همان چیزی را نشان می‌دهد که رندر کرده بود، پس حدس زدن لازم نیست. یک نکته‌ی دیگر هم در همین کتابخانه هست: فیلدی به شکل T | None به همان T تخت می‌شود و اختیاری بودن با بیرون ماندن از required بیان می‌شود، نه با نوع null که در مشخصات رسمی پذیرفته نیست.

وقتی داده نباید از کلاینت رد شود

برای چیزی مثل کلید API یا جریان مجوز، پرسش از راه کلاینت اشتباه است، چون داده از کانتکست مدل عبور می‌کند. برای همین حالت دومی وجود دارد: elicit_url. کاربر به یک نشانی بیرونی می‌رود، تعامل بیرون از پروتکل انجام می‌شود و فقط رضایت یا عدم رضایت برمی‌گردد.

در نسل 2026-07-28 راه پاسخ دادن هم عوض شده است. به جای پیام تأیید، سرور یک خطای اختصاصی با کد -32042 پرتاب می‌کند و کلاینت با تکرار همان درخواست، همراه داده، کار را جلو می‌برد. این کد را از خود بسته خواندم.

from mcp import UrlElicitationRequiredError, types

err = UrlElicitationRequiredError([
    types.ElicitRequestURLParams(
        message="برای ادامه باید دسترسی بدهید",
        url="https://example.com/oauth/authorize",
        elicitation_id="auth-001",
    )
])
print(err.to_error_dict())

خروجی این خطا سه بخش دارد که باید در کلاینت خودتان پیاده کنید.

{'code': -32042, 'message': 'URL elicitation required', 'data': {'elicitations': [{'mode': 'url', 'message': 'Authorize', 'url': 'https://example.com/oauth/authorize', 'elicitationId': 'auth-001'}]}}

در عمل یعنی سرور درخواست را با خطای -32042 متوقف می‌کند، کاربر بیرون از پروتکل کار را تمام می‌کند، و کلاینت همان فراخوانی ابزار را با پاسخ تکرار می‌کند. کتابخانه‌ی پایتون برای همین یک سقف هم دارد: به‌طور پیش‌فرض حداکثر 10 دور پرسش و پاسخ پیش از پرتاب خطای InputRequiredRoundsExceededError.

اگر سرور شما امروز از این پرسش استفاده می‌کند

سه قدم به ترتیب نتیجه می‌دهند. اول، کلاینت خود را روی mode="legacy" بگذارید تا نسخه‌ی 2025-11-25 مذاکره شود؛ این تنها تغییری است که رفتار را درست می‌کند. دوم، در سرور هر سه حالت action را پوشش دهید و فقط در accept به res.data دست بزنید. سوم، اگر واقعاً به نسل تازه نیاز دارید، پرسش را به الگوی چند رفت‌وبرگشتی منتقل کنید و پاسخ را در تکرار همان درخواست برگردانید.

مهاجرت بسته‌ی پایتون خودش یک فصل جدا دارد که تغییر نام‌ها را فهرست می‌کند؛ اگر از FastMCP شروع کرده‌اید همان را بخوانید. راهنمای مخزن رسمی پایتون هم نمونه‌های اجرایی دارد.

اگر قبلاً سرور را روی نسل تازه نوشته‌اید و کلاینتتان پرسش را نمی‌بیند، اول این را چک کنید که نسخه‌ی پروتکل در خروجی شما چیست. اگر 2026-07-28 چاپ شده، مشکل از کد شما نیست. برای سنجیدن خود لایه‌ی پروتکل بدون سرور، پست نسل پروتکل سرور MCP را تشخیص دهیم را بخوانید؛ برای شکست خوردن نسخه‌های قدیمی روی بسته‌ی جدید هم شکستن سرور زمان MCP روی نسخه‌ی 2.3.0 نمونه‌ی دیگری از همین رفتار است.

منابع

  1. مشخصات MCP، نسل 2025-06-18: elicitation
  2. مشخصات MCP، نسل 2025-11-25
  3. مشخصات MCP، نسل 2026-07-28
  4. فهرست تغییرات نسل 2026-07-28
  5. الگوی درخواست‌های چند رفت‌وبرگشتی
  6. مخزن رسمی پایتون SDK مدل کانتکست
  7. صفحه‌ی بسته‌ی mcp در PyPI
  8. راهنمای مهاجرت به نسخه‌ی ۲