chore: import upstream snapshot with attribution
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Waiting to run
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Waiting to run
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
# sampling
|
||||
|
||||
> **Deprecated** in the 2026-07-28 protocol (SEP-2577); functional through the
|
||||
> deprecation window. Migration: call your LLM provider directly from the
|
||||
> server instead of requesting completions through the client.
|
||||
> TODO(maxisbey): revisit before beta.
|
||||
|
||||
A tool that asks the **client's** LLM for a completion mid-call — the inverted
|
||||
MCP direction. The server holds no model API key; it awaits
|
||||
`ctx.session.create_message(...)` and the client's `sampling_callback` answers.
|
||||
Registering the callback is what makes the client advertise the `sampling`
|
||||
capability — there is no separate flag.
|
||||
|
||||
## Run it
|
||||
|
||||
```bash
|
||||
# stdio (default — the client spawns the server as a subprocess)
|
||||
uv run python -m stories.sampling.client
|
||||
|
||||
# HTTP — the client self-hosts the server on a free port, runs, then tears it down
|
||||
uv run python -m stories.sampling.client --http --legacy
|
||||
# same, against the lowlevel-API server variant
|
||||
uv run python -m stories.sampling.client --http --legacy --server server_lowlevel
|
||||
```
|
||||
|
||||
## What to look at
|
||||
|
||||
- `client.py` `main` — `async with Client(target, mode=mode,
|
||||
sampling_callback=on_sample) as client:`. The callback is an ordinary
|
||||
constructor kwarg; registering it is the whole opt-in.
|
||||
- `client.py` `on_sample` — takes `(ClientRequestContext,
|
||||
CreateMessageRequestParams)` and returns a `CreateMessageResult`. A real
|
||||
host calls its LLM provider here; the example returns a canned answer so the
|
||||
round-trip is assertable.
|
||||
- `server.py` — `await ctx.session.create_message(...)` inside the tool body: a
|
||||
server→client request that blocks until the callback answers. There is no
|
||||
`Context.sample()` sugar; reaching `ctx.session` is the public path.
|
||||
- `server_lowlevel.py` — the same call from `ServerRequestContext.session`,
|
||||
with the `CallToolResult` built by hand.
|
||||
|
||||
## Caveats
|
||||
|
||||
- **Legacy-era only.** `sampling/createMessage` is a server-initiated request
|
||||
with no 2026-07-28 wire carrier, so this story runs with `era = "legacy"` and
|
||||
the harness pins the handshake path.
|
||||
- `ctx.session.create_message()` is `@deprecated`; the
|
||||
`# pyright: ignore[reportDeprecated]` is deliberate. The non-deprecated
|
||||
replacement is to call your LLM provider directly from the server (see the
|
||||
banner above) — there is no successor server→client call.
|
||||
- `ctx.session.*` is the interim 2-hop path; a later release will shorten it.
|
||||
- `Client` has no `sampling_capabilities=` kwarg, so the `sampling.tools`
|
||||
sub-capability (tools-in-sampling) is unreachable from the high-level client.
|
||||
Drop to `ClientSession` if you need it.
|
||||
|
||||
## Spec
|
||||
|
||||
[Sampling — client features](https://modelcontextprotocol.io/specification/2025-11-25/client/sampling)
|
||||
|
||||
## See also
|
||||
|
||||
`legacy_elicitation/`, `roots/` — sibling stories that exercise the same legacy
|
||||
server→client request shape.
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Supply a canned sampling_callback and assert its text round-trips through the tool."""
|
||||
|
||||
from mcp_types import CreateMessageRequestParams, CreateMessageResult, TextContent
|
||||
|
||||
from mcp.client import Client, ClientRequestContext
|
||||
from stories._harness import Target, run_client
|
||||
|
||||
|
||||
async def on_sample(context: ClientRequestContext, params: CreateMessageRequestParams) -> CreateMessageResult:
|
||||
# A real host would call its LLM provider here; the example returns a deterministic
|
||||
# canned answer so the round-trip is assertable.
|
||||
return CreateMessageResult(
|
||||
role="assistant",
|
||||
content=TextContent(text="[canned summary]"),
|
||||
model="stub-model",
|
||||
stop_reason="endTurn",
|
||||
)
|
||||
|
||||
|
||||
async def main(target: Target, *, mode: str = "auto") -> None:
|
||||
async with Client(target, mode=mode, sampling_callback=on_sample) as client:
|
||||
result = await client.call_tool("summarize", {"text": "hello world"})
|
||||
|
||||
assert not result.is_error, result
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "[canned summary]", result.content[0].text
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_client(main)
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Sampling primitive: a tool asks the client's LLM for a completion mid-call."""
|
||||
|
||||
from mcp_types import SamplingMessage, TextContent
|
||||
|
||||
from mcp.server.mcpserver import Context, MCPServer
|
||||
from stories._hosting import run_server_from_args
|
||||
|
||||
|
||||
def build_server() -> MCPServer:
|
||||
mcp = MCPServer("sampling-example")
|
||||
|
||||
@mcp.tool(description="Summarize text by asking the host's LLM via sampling/createMessage.")
|
||||
async def summarize(text: str, ctx: Context) -> str:
|
||||
result = await ctx.session.create_message( # pyright: ignore[reportDeprecated]
|
||||
messages=[SamplingMessage(role="user", content=TextContent(text=f"Summarize in one sentence:\n\n{text}"))],
|
||||
max_tokens=200,
|
||||
)
|
||||
assert isinstance(result.content, TextContent)
|
||||
return result.content.text
|
||||
|
||||
return mcp
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_server_from_args(build_server)
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Sampling primitive (lowlevel API): the same server→client round-trip, hand-built."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import mcp_types as types
|
||||
|
||||
from mcp.server.context import ServerRequestContext
|
||||
from mcp.server.lowlevel import Server
|
||||
from stories._hosting import run_server_from_args
|
||||
|
||||
|
||||
def build_server() -> Server[Any]:
|
||||
async def list_tools(
|
||||
ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None
|
||||
) -> types.ListToolsResult:
|
||||
return types.ListToolsResult(
|
||||
tools=[
|
||||
types.Tool(
|
||||
name="summarize",
|
||||
description="Summarize text by asking the host's LLM via sampling/createMessage.",
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {"text": {"type": "string"}},
|
||||
"required": ["text"],
|
||||
},
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult:
|
||||
assert params.name == "summarize"
|
||||
assert params.arguments is not None
|
||||
prompt = f"Summarize in one sentence:\n\n{params.arguments['text']}"
|
||||
result = await ctx.session.create_message( # pyright: ignore[reportDeprecated]
|
||||
messages=[types.SamplingMessage(role="user", content=types.TextContent(text=prompt))],
|
||||
max_tokens=200,
|
||||
)
|
||||
assert isinstance(result.content, types.TextContent)
|
||||
return types.CallToolResult(content=[types.TextContent(text=result.content.text)])
|
||||
|
||||
return Server("sampling-example", on_list_tools=list_tools, on_call_tool=call_tool)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_server_from_args(build_server)
|
||||
Reference in New Issue
Block a user