|
12 | 12 |
|
13 | 13 | import click |
14 | 14 | from mcp.server import ServerRequestContext |
| 15 | +from mcp.server.extension import require_client_extension |
15 | 16 | from mcp.server.mcpserver import Context, MCPServer, RequestStateSecurity |
16 | 17 | from mcp.server.mcpserver.prompts.base import Prompt, UserMessage |
17 | 18 | from mcp.server.streamable_http import EventCallback, EventMessage, EventStore |
| 19 | +from mcp.server.tasks import EXTENSION_ID as TASKS_EXTENSION_ID |
| 20 | +from mcp.server.tasks import Tasks |
18 | 21 | from mcp.shared.exceptions import MCPError |
19 | 22 | from mcp_types import ( |
20 | 23 | AudioContent, |
|
44 | 47 | TextResourceContents, |
45 | 48 | UnsubscribeRequestParams, |
46 | 49 | ) |
47 | | -from mcp_types.jsonrpc import MISSING_REQUIRED_CLIENT_CAPABILITY |
| 50 | +from mcp_types.jsonrpc import INTERNAL_ERROR, MISSING_REQUIRED_CLIENT_CAPABILITY |
48 | 51 | from pydantic import BaseModel, Field |
49 | 52 |
|
50 | 53 | logger = logging.getLogger(__name__) |
@@ -100,9 +103,24 @@ async def replay_events_after(self, last_event_id: EventId, send_callback: Event |
100 | 103 | # Fixed fixture key (RequestStateSecurity requires at least 32 bytes); a real deployment would load a shared secret. |
101 | 104 | _REQUEST_STATE_KEY = b"everything-server-fixture-request-state-key" |
102 | 105 |
|
| 106 | +# SEP-2663 Tasks extension: only these fixture tools are task-augmented, so every |
| 107 | +# other tool keeps its synchronous behaviour even for clients that declare the |
| 108 | +# extension (the tasks conformance scenarios assert e.g. `greet` stays sync). |
| 109 | +TASK_AUGMENTED_TOOLS = frozenset( |
| 110 | + { |
| 111 | + "slow_compute", |
| 112 | + "failing_job", |
| 113 | + "protocol_error_job", |
| 114 | + "confirm_delete", |
| 115 | + "multi_input", |
| 116 | + "test_tool_with_task", |
| 117 | + } |
| 118 | +) |
| 119 | + |
103 | 120 | mcp = MCPServer( |
104 | 121 | name="mcp-conformance-test-server", |
105 | 122 | request_state_security=RequestStateSecurity(keys=[_REQUEST_STATE_KEY]), |
| 123 | + extensions=[Tasks(augment=lambda params: params.name in TASK_AUGMENTED_TOOLS)], |
106 | 124 | ) |
107 | 125 |
|
108 | 126 |
|
@@ -534,6 +552,86 @@ async def test_input_required_result_capabilities(ctx: Context) -> InputRequired |
534 | 552 | return InputRequiredResult(input_requests=requests, request_state="capability-gated") |
535 | 553 |
|
536 | 554 |
|
| 555 | +# SEP-2663 Tasks extension fixtures (io.modelcontextprotocol/tasks). The server |
| 556 | +# decides augmentation per call via the TASK_AUGMENTED_TOOLS allowlist above; |
| 557 | +# `greet` stays deliberately outside it as the synchronous contrast the tasks |
| 558 | +# scenarios assert on. |
| 559 | + |
| 560 | +CONFIRM_SCHEMA = {"type": "object", "properties": {"confirm": {"type": "boolean"}}, "required": ["confirm"]} |
| 561 | + |
| 562 | + |
| 563 | +@mcp.tool() |
| 564 | +def greet(name: str) -> str: |
| 565 | + """Sync-only greeting tool; never task-augmented""" |
| 566 | + return f"Hello, {name}!" |
| 567 | + |
| 568 | + |
| 569 | +@mcp.tool() |
| 570 | +def slow_compute(seconds: float, label: str = "") -> str: |
| 571 | + """Task-supporting compute fixture (SEP-2663). |
| 572 | +
|
| 573 | + Conformance passes durations up to 60s and immediately polls or cancels, so |
| 574 | + the fixture records the requested duration instead of sleeping — the |
| 575 | + born-terminal task is then observable well inside the scenario timeouts. |
| 576 | + """ |
| 577 | + return f"slow_compute({label or 'unlabelled'}) finished after {seconds}s" |
| 578 | + |
| 579 | + |
| 580 | +@mcp.tool() |
| 581 | +async def failing_job(ctx: Context) -> str: |
| 582 | + """Task-required fixture: rejects non-declaring clients, then reports a tool error (SEP-2663)""" |
| 583 | + require_client_extension(ctx.request_context, TASKS_EXTENSION_ID) |
| 584 | + raise RuntimeError("failing_job always reports a tool execution error") |
| 585 | + |
| 586 | + |
| 587 | +@mcp.tool() |
| 588 | +def protocol_error_job() -> str: |
| 589 | + """Task fixture that fails at the protocol level, recording a `failed` task (SEP-2663)""" |
| 590 | + raise MCPError(code=INTERNAL_ERROR, message="protocol_error_job failed at the protocol level") |
| 591 | + |
| 592 | + |
| 593 | +@mcp.tool() |
| 594 | +async def confirm_delete(filename: str, ctx: Context) -> str | InputRequiredResult: |
| 595 | + """Task fixture gathering a deletion confirmation via elicitation (SEP-2322 + SEP-2663)""" |
| 596 | + responses = ctx.input_responses |
| 597 | + if responses and "confirm" in responses: |
| 598 | + answer = responses["confirm"] |
| 599 | + accepted = isinstance(answer, ElicitResult) and answer.action == "accept" |
| 600 | + return f"Deleted {filename}" if accepted else f"Kept {filename}" |
| 601 | + return InputRequiredResult( |
| 602 | + input_requests={ |
| 603 | + "confirm": ElicitRequest( |
| 604 | + params=ElicitRequestFormParams(message=f"Really delete {filename}?", requested_schema=CONFIRM_SCHEMA) |
| 605 | + ) |
| 606 | + } |
| 607 | + ) |
| 608 | + |
| 609 | + |
| 610 | +@mcp.tool() |
| 611 | +async def multi_input(ctx: Context) -> str | InputRequiredResult: |
| 612 | + """Task fixture fanning out two simultaneous elicitations (SEP-2663 partial fulfillment probe)""" |
| 613 | + responses = ctx.input_responses |
| 614 | + if responses and {"first", "second"} <= responses.keys(): |
| 615 | + return "multi_input received both responses" |
| 616 | + return InputRequiredResult( |
| 617 | + input_requests={ |
| 618 | + "first": _name_elicitation("First input?"), |
| 619 | + "second": _name_elicitation("Second input?"), |
| 620 | + } |
| 621 | + ) |
| 622 | + |
| 623 | + |
| 624 | +@mcp.tool() |
| 625 | +async def test_tool_with_task(ctx: Context) -> str | InputRequiredResult: |
| 626 | + """SEP-2663 MRTR-to-task composition fixture: gathers a name, then the final round becomes a task""" |
| 627 | + responses = ctx.input_responses |
| 628 | + if responses and "user_name" in responses: |
| 629 | + answer = responses["user_name"] |
| 630 | + name = answer.content.get("name", "stranger") if isinstance(answer, ElicitResult) and answer.content else "?" |
| 631 | + return f"Hello, {name}! The gathered-name task is complete." |
| 632 | + return InputRequiredResult(input_requests={"user_name": _name_elicitation()}) |
| 633 | + |
| 634 | + |
537 | 635 | # SEP-1613 / SEP-2106 JSON Schema 2020-12 fixture: a tool whose inputSchema carries |
538 | 636 | # the full set of 2020-12 keywords the conformance scenario asserts on. |
539 | 637 |
|
|
0 commit comments