mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-22 13:34:17 +02:00
Let a server answer argument-completion requests (#4582)
* Add server-side argument completion (@mcp.completion) * Reference CompletionValues directly in cast so the import reads as used * Import completion types from mcp_types, not the fastmcp.types mirror * Fix test imports after dropping the fastmcp.types mirror * Fix change-register example import after dropping the types mirror * Enforce 100-value completion cap; make docs example runnable * Document completion authorization contract * Offload sync completion handlers to threadpool * Exclude bare str from completion return type * Pass Any-typed value in bare-string rejection test * Point completion authoring types to mcp_types in v4 notes
This commit is contained in:
parent
611a35861d
commit
7417e974f4
7 changed files with 723 additions and 1 deletions
|
|
@ -202,6 +202,34 @@ There is deliberately no compatibility alias for the old spelling.
|
|||
|
||||
*Verify:* `fastmcp_slim/fastmcp/server/middleware/caching.py`.
|
||||
|
||||
### Server-side argument completion — New (opt-in feature)
|
||||
|
||||
A FastMCP server can now answer `completion/complete` requests, suggesting values for prompt arguments and resource-template parameters as a user types. Previously a FastMCP *client* could call `complete()` but a FastMCP *server* had no way to respond — the method was unregistered, so it returned `-32601` (method-not-found) on both eras. The new `@mcp.completion` decorator registers a single server-level handler that receives the reference (a `PromptReference` or `ResourceTemplateReference`), the `CompletionArgument` being completed, and the optional `CompletionContext` of already-supplied argument values, and returns candidates — a list of strings, a `Completion` (to carry the `total`/`has_more` pagination hints), or `None`/empty for a reference it does not recognize (which yields an empty completion, not an error).
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from mcp_types import PromptReference
|
||||
|
||||
mcp = FastMCP("Completion Server")
|
||||
|
||||
|
||||
@mcp.prompt
|
||||
def write_poem(theme: str) -> str:
|
||||
return f"Write a poem about {theme}"
|
||||
|
||||
|
||||
@mcp.completion
|
||||
def complete(ref, argument, context):
|
||||
if isinstance(ref, PromptReference) and argument.name == "theme":
|
||||
options = ["nature", "love", "adventure"]
|
||||
return [o for o in options if o.startswith(argument.value)]
|
||||
return None
|
||||
```
|
||||
|
||||
The completions capability is declared exactly when a handler exists: `add_completion_handler` registers the low-level `completion/complete` handler, and the SDK derives the capability from that handler's presence — a server with no completion handler does not advertise it. FastMCP does not hand-set the capability. The single-handler shape mirrors the SDK's own `completion/complete` surface and FastMCP's existing client-side `Client.complete()`, and it slots into the `@mcp.tool`/`@mcp.prompt`/`@mcp.resource` decorator lineup as another server-level `@mcp.<verb>` registration rather than inventing a per-argument sub-decorator idiom. It works identically on the handshake and modern (`2026-07-28`) eras, since `completion/complete` is a request/response method that flows on every era. The authoring types — `PromptReference`, `ResourceTemplateReference`, `CompletionArgument`, `CompletionContext`, and `Completion` — are imported from `mcp_types`, not `fastmcp.types`.
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/server/completions.py` (handler type + `normalize_completion`), `fastmcp_slim/fastmcp/server/server.py` (`completion` decorator, `add_completion_handler`), `fastmcp_slim/fastmcp/server/mixins/mcp_operations.py` (`_on_complete`), `tests/server/test_completions.py`, `docs/servers/completions.mdx`.
|
||||
|
||||
## Client
|
||||
|
||||
The `fastmcp.Client` public API is largely preserved. The client stays a wrapper around `mcp.ClientSession`; the first-class `mcp.client.Client` is deliberately not adopted. Two client-surface changes are called out below: the connection `mode` default flips to `"auto"`, and `extensions=` / `result_claims=` are newly surfaced.
|
||||
|
|
|
|||
|
|
@ -144,6 +144,7 @@
|
|||
"pages": [
|
||||
"servers/elicitation",
|
||||
"servers/sampling",
|
||||
"servers/completions",
|
||||
"servers/progress",
|
||||
"servers/logging",
|
||||
"servers/pagination",
|
||||
|
|
|
|||
177
docs/servers/completions.mdx
Normal file
177
docs/servers/completions.mdx
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
---
|
||||
title: Argument Completion
|
||||
sidebarTitle: Completions
|
||||
description: Suggest values for prompt arguments and resource template parameters as the user types.
|
||||
icon: list-check
|
||||
---
|
||||
|
||||
import { VersionBadge } from "/snippets/version-badge.mdx"
|
||||
|
||||
<VersionBadge version="4.0.0" />
|
||||
|
||||
Argument completion lets a server suggest values while a user fills in a prompt argument or a resource template parameter. As the user types, the client sends a `completion/complete` request naming the prompt or template, the argument being completed, and the partial value so far. The server answers with candidate strings, which the client offers as autocomplete suggestions.
|
||||
|
||||
This is the server side of the feature. A client requests completions with [`Client.complete()`](/clients/client); this page covers how a server answers.
|
||||
|
||||
## Register a completion handler
|
||||
|
||||
A server has a single completion handler, registered with the `@mcp.completion` decorator. The handler receives every completion request and switches on which reference and argument is being completed.
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from mcp_types import PromptReference
|
||||
|
||||
mcp = FastMCP("Completion Server")
|
||||
|
||||
|
||||
@mcp.prompt
|
||||
def write_poem(theme: str) -> str:
|
||||
return f"Write a poem about {theme}"
|
||||
|
||||
|
||||
@mcp.completion
|
||||
def complete(ref, argument, context):
|
||||
if isinstance(ref, PromptReference) and ref.name == "write_poem":
|
||||
if argument.name == "theme":
|
||||
options = ["nature", "love", "adventure"]
|
||||
return [o for o in options if o.startswith(argument.value)]
|
||||
return None
|
||||
```
|
||||
|
||||
The handler is called with three values:
|
||||
|
||||
- `ref`: which component is being completed — a `PromptReference` (carrying the prompt `name`) or a `ResourceTemplateReference` (carrying the template `uri`).
|
||||
- `argument`: a `CompletionArgument` with the argument `name` and the partial `value` typed so far.
|
||||
- `context`: an optional `CompletionContext` carrying the values of arguments the user has already supplied (see [Using already-supplied arguments](#using-already-supplied-arguments)).
|
||||
|
||||
Filter your candidates against `argument.value` so the suggestions narrow as the user types. Returning `None` means "I have no suggestions for this reference and argument" — the client receives an empty list, which is the correct answer for a reference the server does not recognize.
|
||||
|
||||
<Tip>
|
||||
Registering a completion handler declares the server's completions capability during the handshake. A server with no handler does not advertise the capability, and a client that checks capabilities before calling will skip completion requests entirely. This works the same way on both the handshake and modern protocol eras.
|
||||
</Tip>
|
||||
|
||||
## Completing resource template parameters
|
||||
|
||||
The same handler answers completion for resource template parameters. A `ResourceTemplateReference` identifies the template by its URI template, and `argument.name` is the parameter being completed.
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from mcp_types import ResourceTemplateReference
|
||||
|
||||
mcp = FastMCP("Completion Server")
|
||||
|
||||
REPOS = ["fastmcp", "prefect", "marvin"]
|
||||
|
||||
|
||||
@mcp.resource("github://{owner}/{repo}")
|
||||
def repo_readme(owner: str, repo: str) -> str:
|
||||
return f"README for {owner}/{repo}"
|
||||
|
||||
|
||||
@mcp.completion
|
||||
def complete(ref, argument, context):
|
||||
if isinstance(ref, ResourceTemplateReference):
|
||||
if ref.uri == "github://{owner}/{repo}" and argument.name == "repo":
|
||||
return [r for r in REPOS if r.startswith(argument.value)]
|
||||
return None
|
||||
```
|
||||
|
||||
Because a single handler answers for every prompt and template, a server that completes several components branches on `ref` first, then on `argument.name`. Grouping the branches by reference keeps the handler readable as it grows.
|
||||
|
||||
## Using already-supplied arguments
|
||||
|
||||
Completions often depend on values the user has already entered. A repository suggestion, for example, depends on which owner was chosen. The client sends those resolved values in the completion context, and the handler reads them from `context.arguments`.
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from mcp_types import ResourceTemplateReference
|
||||
|
||||
mcp = FastMCP("Completion Server")
|
||||
|
||||
REPOS_BY_OWNER = {
|
||||
"prefecthq": ["fastmcp", "prefect", "marvin"],
|
||||
"python": ["cpython", "mypy"],
|
||||
}
|
||||
|
||||
|
||||
@mcp.resource("github://{owner}/{repo}")
|
||||
def repo_readme(owner: str, repo: str) -> str:
|
||||
return f"README for {owner}/{repo}"
|
||||
|
||||
|
||||
@mcp.completion
|
||||
def complete(ref, argument, context):
|
||||
if isinstance(ref, ResourceTemplateReference) and argument.name == "repo":
|
||||
owner = context.arguments.get("owner") if context and context.arguments else None
|
||||
repos = REPOS_BY_OWNER.get(owner or "", [])
|
||||
return [r for r in repos if r.startswith(argument.value)]
|
||||
return None
|
||||
```
|
||||
|
||||
Here the suggestions for `repo` are scoped to the `owner` the user already selected. The context is only present once at least one argument has been resolved, so guard against `context` being `None`.
|
||||
|
||||
## Returning results
|
||||
|
||||
A handler may return any of three things:
|
||||
|
||||
- A list of strings — the simplest form, wrapped into a completion response automatically.
|
||||
- `None` — treated as an empty completion, for references and arguments the handler does not recognize.
|
||||
- A `Completion` object — when you want to include pagination hints alongside the values.
|
||||
|
||||
The MCP protocol caps a single response at 100 values. When more candidates exist, return a `Completion` and set `total` (how many candidates match in all) and `has_more` (whether values were truncated) so the client can indicate that the list is partial.
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from mcp_types import Completion, PromptReference
|
||||
|
||||
mcp = FastMCP("Completion Server")
|
||||
|
||||
ALL_CITIES = ["Paris", "Prague", "Portland", "Phoenix", "Perth"]
|
||||
|
||||
|
||||
@mcp.prompt
|
||||
def pick_city(city: str) -> str:
|
||||
return f"Tell me about {city}"
|
||||
|
||||
|
||||
def search_cities(prefix: str) -> list[str]:
|
||||
# A real lookup might return thousands of matches; ALL_CITIES stands in.
|
||||
return [c for c in ALL_CITIES if c.startswith(prefix)]
|
||||
|
||||
|
||||
@mcp.completion
|
||||
def complete(ref, argument, context):
|
||||
if isinstance(ref, PromptReference) and argument.name == "city":
|
||||
matches = search_cities(argument.value)
|
||||
return Completion(
|
||||
values=matches[:100],
|
||||
total=len(matches),
|
||||
has_more=len(matches) > 100,
|
||||
)
|
||||
return None
|
||||
```
|
||||
|
||||
## Accessing the request context
|
||||
|
||||
A completion handler may be sync or async, and it can reach the active request through FastMCP's dependency functions the same way any handler does. Use [`get_context()`](/servers/context) to access session information, authentication, or server state while computing suggestions.
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.dependencies import get_context
|
||||
from mcp_types import PromptReference
|
||||
|
||||
mcp = FastMCP("Completion Server")
|
||||
|
||||
|
||||
@mcp.completion
|
||||
async def complete(ref, argument, context):
|
||||
ctx = get_context()
|
||||
await ctx.debug(f"Completing {argument.name!r} for {ref}")
|
||||
...
|
||||
```
|
||||
|
||||
## Authorization
|
||||
|
||||
Completion runs behind the server's connection-level authentication: an unauthenticated client never reaches the handler. It is independent of per-component `auth=`, though. FastMCP does not resolve the referenced prompt or resource template, so a completion request is not filtered by that component's visibility the way `prompts/get` or a resource read is — the single handler answers for whatever reference the client names.
|
||||
|
||||
A completion response carries only candidate strings for one argument, never component content or schema, so this exposes nothing about a hidden component on its own. If a handler computes candidates that should themselves be restricted — matching a prompt hidden from unauthorized callers, say — check the auth context inside the handler (via [`get_context()`](/servers/context)) and return `None` when the caller is not permitted.
|
||||
87
fastmcp_slim/fastmcp/server/completions.py
Normal file
87
fastmcp_slim/fastmcp/server/completions.py
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
"""Server-side argument completion for FastMCP.
|
||||
|
||||
A completion request names a reference — a specific prompt or resource
|
||||
template — and the argument being completed, plus a context of the argument
|
||||
values already supplied. The server answers with candidate string values.
|
||||
|
||||
FastMCP surfaces this as a single server-level handler registered with
|
||||
``@mcp.completion``, mirroring the MCP SDK's own ``completion/complete`` shape
|
||||
and FastMCP's client-side ``Client.complete()``. The handler receives the
|
||||
reference, the argument, and the optional context, and returns candidates for
|
||||
whichever reference/argument pair it recognizes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
import mcp_types
|
||||
|
||||
CompletionReference = mcp_types.PromptReference | mcp_types.ResourceTemplateReference
|
||||
"""The reference a completion request targets: a prompt or a resource template."""
|
||||
|
||||
CompletionValues = mcp_types.Completion | list[str] | tuple[str, ...] | None
|
||||
"""What a completion handler may return.
|
||||
|
||||
- ``Completion`` — used verbatim (carries the optional ``total`` / ``has_more``
|
||||
pagination hints).
|
||||
- ``list[str]`` / ``tuple[str, ...]`` — wrapped into a ``Completion``. A bare
|
||||
``str`` is deliberately excluded: it satisfies ``Sequence[str]`` but is almost
|
||||
always a mistake, and ``normalize_completion`` rejects it at runtime — naming
|
||||
concrete collections keeps the annotation and the runtime guard in agreement.
|
||||
- ``None`` — treated as "no candidates" (an empty completion).
|
||||
"""
|
||||
|
||||
CompletionHandler = Callable[
|
||||
[
|
||||
CompletionReference,
|
||||
mcp_types.CompletionArgument,
|
||||
mcp_types.CompletionContext | None,
|
||||
],
|
||||
Awaitable[CompletionValues] | CompletionValues,
|
||||
]
|
||||
"""A server's completion handler.
|
||||
|
||||
Called with the reference, the argument being completed, and the optional
|
||||
context of already-supplied argument values. May be sync or async.
|
||||
"""
|
||||
|
||||
|
||||
# The MCP completion contract caps `values` at 100 candidates per response.
|
||||
MAX_COMPLETION_VALUES = 100
|
||||
|
||||
|
||||
def normalize_completion(result: CompletionValues) -> mcp_types.Completion:
|
||||
"""Coerce a handler's return value into a wire ``Completion``.
|
||||
|
||||
A returned ``str`` is rejected: it is almost always a mistake (the value
|
||||
would iterate into one-character candidates), so it raises rather than
|
||||
silently producing surprising output.
|
||||
|
||||
The MCP contract caps a completion at 100 values, so a longer result is
|
||||
truncated to the first 100 with ``has_more`` set — a handler that returns
|
||||
thousands of matches emits a conforming response rather than an oversized
|
||||
one that strict clients reject.
|
||||
"""
|
||||
if result is None:
|
||||
return mcp_types.Completion(values=[])
|
||||
if isinstance(result, str):
|
||||
raise TypeError(
|
||||
"A completion handler returned a str; return a list of strings "
|
||||
"(for example, [value]) or a Completion instead."
|
||||
)
|
||||
if isinstance(result, mcp_types.Completion):
|
||||
completion = result
|
||||
else:
|
||||
completion = mcp_types.Completion(values=list(result))
|
||||
|
||||
if len(completion.values) > MAX_COMPLETION_VALUES:
|
||||
total = (
|
||||
completion.total if completion.total is not None else len(completion.values)
|
||||
)
|
||||
return mcp_types.Completion(
|
||||
values=completion.values[:MAX_COMPLETION_VALUES],
|
||||
total=total,
|
||||
has_more=True,
|
||||
)
|
||||
return completion
|
||||
|
|
@ -2,8 +2,9 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
from collections.abc import Sequence
|
||||
from typing import TYPE_CHECKING, Any, TypeVar
|
||||
from typing import TYPE_CHECKING, Any, TypeVar, cast
|
||||
|
||||
import mcp_types
|
||||
from mcp.server.context import ServerRequestContext
|
||||
|
|
@ -11,6 +12,7 @@ from mcp.shared.exceptions import MCPError
|
|||
from mcp_types import (
|
||||
INVALID_PARAMS,
|
||||
CallToolRequestParams,
|
||||
CompleteRequestParams,
|
||||
EmptyResult,
|
||||
GetPromptRequestParams,
|
||||
PaginatedRequestParams,
|
||||
|
|
@ -25,9 +27,14 @@ from fastmcp.exceptions import (
|
|||
NotFoundError,
|
||||
to_mcp_error,
|
||||
)
|
||||
from fastmcp.server.completions import CompletionValues, normalize_completion
|
||||
from fastmcp.server.dependencies import bind_request_context, extract_version_spec
|
||||
from fastmcp.server.tasks.config import TaskMeta
|
||||
from fastmcp.tools.base import InputRequiredToolResult
|
||||
from fastmcp.utilities.async_utils import (
|
||||
call_sync_fn_in_threadpool,
|
||||
is_coroutine_function,
|
||||
)
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
from fastmcp.utilities.pagination import paginate_sequence
|
||||
from fastmcp.utilities.versions import VersionSpec, dedupe_with_versions
|
||||
|
|
@ -391,3 +398,54 @@ class MCPOperationsMixin:
|
|||
session_id = _log_level_session_key(rc.session)
|
||||
self._client_log_levels[session_id] = params.level
|
||||
return EmptyResult()
|
||||
|
||||
async def _on_complete(
|
||||
self: FastMCP,
|
||||
ctx: ServerRequestContext,
|
||||
params: CompleteRequestParams,
|
||||
) -> mcp_types.CompleteResult:
|
||||
"""Handle MCP 'completion/complete' requests.
|
||||
|
||||
Routes to the server's registered completion handler (set via
|
||||
``@mcp.completion``). The handler switches on the reference and argument
|
||||
and returns candidate values. A handler that does not recognize the
|
||||
reference/argument returns ``None`` or an empty sequence, which becomes
|
||||
an empty completion rather than an error — an unknown reference is not a
|
||||
protocol failure. This handler is registered on the low-level server
|
||||
only once a completion handler exists, so the completions capability is
|
||||
declared exactly when the server can answer.
|
||||
"""
|
||||
with bind_request_context(ctx):
|
||||
logger.debug(f"[{self.name}] Handler called: complete %s", params.ref)
|
||||
handler = self._completion_handler
|
||||
if handler is None:
|
||||
return mcp_types.CompleteResult(
|
||||
completion=mcp_types.Completion(values=[])
|
||||
)
|
||||
|
||||
if is_coroutine_function(handler):
|
||||
raw = handler(params.ref, params.argument, params.context)
|
||||
else:
|
||||
# A sync handler may perform blocking work (a database lookup,
|
||||
# say); run it in a threadpool so it does not stall the event
|
||||
# loop, matching how sync tools/prompts/resources are invoked.
|
||||
raw = await call_sync_fn_in_threadpool(
|
||||
handler, params.ref, params.argument, params.context
|
||||
)
|
||||
result = await raw if inspect.isawaitable(raw) else raw
|
||||
completion = normalize_completion(cast(CompletionValues, result))
|
||||
return mcp_types.CompleteResult(completion=completion)
|
||||
|
||||
def _register_completion_handler(self: FastMCP) -> None:
|
||||
"""Register the low-level ``completion/complete`` handler.
|
||||
|
||||
Called when a completion handler is set (via
|
||||
``add_completion_handler``) so the SDK derives the completions
|
||||
capability from the handler's presence. Registration is idempotent —
|
||||
re-registering replaces the handler.
|
||||
"""
|
||||
self._mcp_server.add_request_handler(
|
||||
"completion/complete",
|
||||
CompleteRequestParams,
|
||||
self._on_complete,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ from fastmcp.resources.security import (
|
|||
from fastmcp.resources.template import ResourceTemplate
|
||||
from fastmcp.server.auth import AuthCheck, AuthContext, AuthProvider, run_auth_checks
|
||||
from fastmcp.server.caching import build_cache_hints
|
||||
from fastmcp.server.completions import CompletionHandler
|
||||
from fastmcp.server.lifespan import Lifespan
|
||||
from fastmcp.server.low_level import LowLevelServer
|
||||
from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext
|
||||
|
|
@ -513,6 +514,12 @@ class FastMCP(
|
|||
experimental_capabilities or {}
|
||||
)
|
||||
|
||||
# Server-level argument completion handler (set via @mcp.completion).
|
||||
# The completions capability is declared only once this is set, because
|
||||
# add_completion_handler registers the low-level completion/complete
|
||||
# handler at that point (the SDK derives the capability from the handler).
|
||||
self._completion_handler: CompletionHandler | None = None
|
||||
|
||||
self.middleware: list[Middleware] = list(middleware or [])
|
||||
|
||||
if dereference_schemas:
|
||||
|
|
@ -2196,6 +2203,84 @@ class FastMCP(
|
|||
auth=auth,
|
||||
)
|
||||
|
||||
def add_completion_handler(self, handler: CompletionHandler) -> None:
|
||||
"""Register the server's argument-completion handler.
|
||||
|
||||
A server has a single completion handler that answers every
|
||||
`completion/complete` request, switching on the reference (a prompt or
|
||||
resource template) and the argument being completed. Registering it also
|
||||
registers the low-level `completion/complete` handler, which is what
|
||||
makes the SDK declare the completions capability — so the capability is
|
||||
advertised exactly when the server can answer. Calling this again
|
||||
replaces the handler.
|
||||
|
||||
Args:
|
||||
handler: A callable taking the reference, the
|
||||
`CompletionArgument`, and the optional `CompletionContext`, and
|
||||
returning candidate values (a `Completion`, a list of strings,
|
||||
or None). May be sync or async.
|
||||
"""
|
||||
self._completion_handler = handler
|
||||
self._register_completion_handler()
|
||||
|
||||
@overload
|
||||
def completion(self, handler: CompletionHandler) -> CompletionHandler: ...
|
||||
|
||||
@overload
|
||||
def completion(
|
||||
self,
|
||||
) -> Callable[[CompletionHandler], CompletionHandler]: ...
|
||||
|
||||
def completion(
|
||||
self,
|
||||
handler: CompletionHandler | None = None,
|
||||
) -> CompletionHandler | Callable[[CompletionHandler], CompletionHandler]:
|
||||
"""Decorator to register the server's argument-completion handler.
|
||||
|
||||
The handler answers `completion/complete` requests for prompt arguments
|
||||
and resource-template parameters. It receives the reference being
|
||||
completed, the argument (its name and the partial value typed so far),
|
||||
and the context of arguments already supplied, and returns candidate
|
||||
values. Return a list of strings, a `Completion` (to include pagination
|
||||
hints), or None when the reference/argument is not one it handles — an
|
||||
unhandled reference yields an empty completion, not an error.
|
||||
|
||||
Registering a handler declares the completions capability; a server with
|
||||
none does not advertise it. This works identically on the handshake and
|
||||
modern protocol eras.
|
||||
|
||||
Supports both `@mcp.completion` and `@mcp.completion()`.
|
||||
|
||||
Example:
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from mcp_types import Completion, PromptReference
|
||||
|
||||
mcp = FastMCP("Completion Server")
|
||||
|
||||
@mcp.prompt
|
||||
def poem(theme: str) -> str:
|
||||
return f"Write a poem about {theme}"
|
||||
|
||||
@mcp.completion
|
||||
def complete(ref, argument, context):
|
||||
if isinstance(ref, PromptReference) and ref.name == "poem":
|
||||
if argument.name == "theme":
|
||||
options = ["nature", "love", "adventure"]
|
||||
return [o for o in options if o.startswith(argument.value)]
|
||||
return None
|
||||
```
|
||||
"""
|
||||
|
||||
def register(fn: CompletionHandler) -> CompletionHandler:
|
||||
self.add_completion_handler(fn)
|
||||
return fn
|
||||
|
||||
if handler is None:
|
||||
return register
|
||||
return register(handler)
|
||||
|
||||
def mount(
|
||||
self,
|
||||
server: FastMCP[LifespanResultT],
|
||||
|
|
|
|||
286
tests/server/test_completions.py
Normal file
286
tests/server/test_completions.py
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
"""Server-side argument completion (`completion/complete`).
|
||||
|
||||
A FastMCP server answers completion requests through a single handler
|
||||
registered with `@mcp.completion`. These tests cover both reference kinds
|
||||
(prompt arguments and resource-template parameters), the capability
|
||||
declaration, graceful handling of unrecognized references, and parity across
|
||||
the handshake (`mode="legacy"`) and modern (`mode="auto"`) protocol eras.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from mcp_types import (
|
||||
Completion,
|
||||
CompletionArgument,
|
||||
CompletionContext,
|
||||
PromptReference,
|
||||
ResourceTemplateReference,
|
||||
)
|
||||
|
||||
from fastmcp import Client, FastMCP
|
||||
from fastmcp.server.completions import normalize_completion
|
||||
|
||||
# Both protocol eras the connection may negotiate.
|
||||
MODES = ["legacy", "auto"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def completion_server() -> FastMCP:
|
||||
"""A server that completes a prompt argument and a template parameter."""
|
||||
mcp = FastMCP("completion-server")
|
||||
|
||||
@mcp.prompt
|
||||
def poem(theme: str) -> str:
|
||||
return f"Write a poem about {theme}"
|
||||
|
||||
@mcp.resource("data://item/{item_id}")
|
||||
def item(item_id: str) -> str:
|
||||
return f"item-{item_id}"
|
||||
|
||||
@mcp.completion
|
||||
def complete(ref, argument, context):
|
||||
if isinstance(ref, PromptReference) and ref.name == "poem":
|
||||
if argument.name == "theme":
|
||||
options = ["nature", "love", "adventure"]
|
||||
return [o for o in options if o.startswith(argument.value)]
|
||||
if isinstance(ref, ResourceTemplateReference):
|
||||
if ref.uri == "data://item/{item_id}" and argument.name == "item_id":
|
||||
return ["1", "2", "3"]
|
||||
return None
|
||||
|
||||
return mcp
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", MODES)
|
||||
async def test_prompt_argument_completion_returns_candidates(completion_server, mode):
|
||||
async with Client(completion_server, mode=mode) as client:
|
||||
result = await client.complete(
|
||||
PromptReference(name="poem"),
|
||||
{"name": "theme", "value": "n"},
|
||||
)
|
||||
assert result.values == ["nature"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", MODES)
|
||||
async def test_resource_template_completion_returns_candidates(completion_server, mode):
|
||||
ref = ResourceTemplateReference(uri="data://item/{item_id}")
|
||||
async with Client(completion_server, mode=mode) as client:
|
||||
result = await client.complete(ref, {"name": "item_id", "value": ""})
|
||||
assert result.values == ["1", "2", "3"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", MODES)
|
||||
async def test_capability_declared_when_handler_registered(completion_server, mode):
|
||||
async with Client(completion_server, mode=mode) as client:
|
||||
capabilities = client.server_capabilities
|
||||
assert capabilities is not None
|
||||
assert capabilities.completions is not None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", MODES)
|
||||
async def test_capability_absent_without_handler(mode):
|
||||
mcp = FastMCP("no-completion")
|
||||
|
||||
@mcp.prompt
|
||||
def poem(theme: str) -> str:
|
||||
return f"Write a poem about {theme}"
|
||||
|
||||
async with Client(mcp, mode=mode) as client:
|
||||
capabilities = client.server_capabilities
|
||||
assert capabilities is not None
|
||||
assert capabilities.completions is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", MODES)
|
||||
async def test_unregistered_ref_returns_empty_completion(completion_server, mode):
|
||||
async with Client(completion_server, mode=mode) as client:
|
||||
result = await client.complete(
|
||||
PromptReference(name="does-not-exist"),
|
||||
{"name": "theme", "value": "n"},
|
||||
)
|
||||
assert result.values == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", MODES)
|
||||
async def test_unregistered_argument_returns_empty_completion(completion_server, mode):
|
||||
async with Client(completion_server, mode=mode) as client:
|
||||
result = await client.complete(
|
||||
PromptReference(name="poem"),
|
||||
{"name": "unknown_argument", "value": "x"},
|
||||
)
|
||||
assert result.values == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", MODES)
|
||||
async def test_completion_context_reaches_handler(mode):
|
||||
"""The already-supplied argument values arrive as the handler's context."""
|
||||
mcp = FastMCP("context-server")
|
||||
|
||||
@mcp.prompt
|
||||
def compose(owner: str, repo: str) -> str:
|
||||
return f"{owner}/{repo}"
|
||||
|
||||
seen: dict[str, str] = {}
|
||||
|
||||
@mcp.completion
|
||||
def complete(ref, argument, context):
|
||||
if context is not None and context.arguments:
|
||||
seen.update(context.arguments)
|
||||
return ["fastmcp"]
|
||||
|
||||
async with Client(mcp, mode=mode) as client:
|
||||
result = await client.complete(
|
||||
PromptReference(name="compose"),
|
||||
{"name": "repo", "value": "fast"},
|
||||
context_arguments={"owner": "prefecthq"},
|
||||
)
|
||||
assert result.values == ["fastmcp"]
|
||||
assert seen == {"owner": "prefecthq"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", MODES)
|
||||
async def test_completion_object_passes_through_pagination_hints(mode):
|
||||
"""Returning a Completion preserves its total / has_more hints."""
|
||||
mcp = FastMCP("hints-server")
|
||||
|
||||
@mcp.prompt
|
||||
def poem(theme: str) -> str:
|
||||
return f"Write a poem about {theme}"
|
||||
|
||||
@mcp.completion
|
||||
def complete(ref, argument, context):
|
||||
return Completion(values=["nature"], total=42, has_more=True)
|
||||
|
||||
async with Client(mcp, mode=mode) as client:
|
||||
result = await client.complete(
|
||||
PromptReference(name="poem"),
|
||||
{"name": "theme", "value": "n"},
|
||||
)
|
||||
assert result.values == ["nature"]
|
||||
assert result.total == 42
|
||||
assert result.has_more is True
|
||||
|
||||
|
||||
async def test_async_completion_handler_is_awaited():
|
||||
mcp = FastMCP("async-server")
|
||||
|
||||
@mcp.prompt
|
||||
def poem(theme: str) -> str:
|
||||
return f"Write a poem about {theme}"
|
||||
|
||||
@mcp.completion
|
||||
async def complete(ref, argument, context):
|
||||
return ["async-value"]
|
||||
|
||||
async with Client(mcp) as client:
|
||||
result = await client.complete(
|
||||
PromptReference(name="poem"),
|
||||
{"name": "theme", "value": ""},
|
||||
)
|
||||
assert result.values == ["async-value"]
|
||||
|
||||
|
||||
async def test_sync_completion_handler_runs_off_event_loop_thread():
|
||||
"""A sync handler is offloaded to a threadpool so blocking work in it can't
|
||||
stall the event loop, matching how sync tools/prompts/resources run."""
|
||||
mcp = FastMCP("threadpool-server")
|
||||
|
||||
@mcp.prompt
|
||||
def poem(theme: str) -> str:
|
||||
return f"Write a poem about {theme}"
|
||||
|
||||
handler_thread: dict[str, int] = {}
|
||||
|
||||
@mcp.completion
|
||||
def complete(ref, argument, context):
|
||||
handler_thread["ident"] = threading.get_ident()
|
||||
return ["value"]
|
||||
|
||||
main_thread = threading.get_ident()
|
||||
async with Client(mcp) as client:
|
||||
result = await client.complete(
|
||||
PromptReference(name="poem"),
|
||||
{"name": "theme", "value": ""},
|
||||
)
|
||||
assert result.values == ["value"]
|
||||
assert handler_thread["ident"] != main_thread
|
||||
|
||||
|
||||
def test_completion_decorator_registers_handler():
|
||||
"""`@mcp.completion` (bare) registers the handler and the wire capability."""
|
||||
mcp = FastMCP("decorator-server")
|
||||
|
||||
@mcp.completion
|
||||
def complete(ref, argument, context):
|
||||
return None
|
||||
|
||||
assert mcp._completion_handler is complete
|
||||
assert "completion/complete" in mcp._mcp_server._request_handlers
|
||||
|
||||
|
||||
def test_completion_decorator_called_form_registers_handler():
|
||||
"""`@mcp.completion()` (called) registers the handler too."""
|
||||
mcp = FastMCP("decorator-server")
|
||||
|
||||
@mcp.completion()
|
||||
def complete(ref, argument, context):
|
||||
return None
|
||||
|
||||
assert mcp._completion_handler is complete
|
||||
assert "completion/complete" in mcp._mcp_server._request_handlers
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value, expected",
|
||||
[
|
||||
(None, []),
|
||||
([], []),
|
||||
(["a", "b"], ["a", "b"]),
|
||||
(("a", "b"), ["a", "b"]),
|
||||
],
|
||||
)
|
||||
def test_normalize_completion_coerces_values(value, expected):
|
||||
assert normalize_completion(value).values == expected
|
||||
|
||||
|
||||
def test_normalize_completion_passes_completion_through():
|
||||
completion = Completion(values=["x"], total=1)
|
||||
assert normalize_completion(completion) is completion
|
||||
|
||||
|
||||
def test_normalize_completion_truncates_oversized_list_to_100():
|
||||
values = [str(i) for i in range(150)]
|
||||
completion = normalize_completion(values)
|
||||
assert len(completion.values) == 100
|
||||
assert completion.total == 150
|
||||
assert completion.has_more is True
|
||||
|
||||
|
||||
def test_normalize_completion_truncates_oversized_completion_and_keeps_total():
|
||||
completion = normalize_completion(
|
||||
Completion(values=[str(i) for i in range(150)], total=500)
|
||||
)
|
||||
assert len(completion.values) == 100
|
||||
assert completion.total == 500
|
||||
assert completion.has_more is True
|
||||
|
||||
|
||||
def test_normalize_completion_rejects_bare_string():
|
||||
# A bare str is excluded from the handler return type, so this passes it
|
||||
# through an Any-typed value to exercise the runtime guard for callers who
|
||||
# bypass type checking.
|
||||
bad: Any = "oops"
|
||||
with pytest.raises(TypeError, match="return a list of strings"):
|
||||
normalize_completion(bad)
|
||||
|
||||
|
||||
def test_completion_argument_and_context_types_importable():
|
||||
"""The completion authoring types are importable from mcp_types."""
|
||||
argument = CompletionArgument(name="theme", value="n")
|
||||
context = CompletionContext(arguments={"owner": "prefecthq"})
|
||||
assert argument.name == "theme"
|
||||
assert context.arguments == {"owner": "prefecthq"}
|
||||
Loading…
Add table
Add a link
Reference in a new issue