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:
Jeremiah Lowin 2026-07-21 12:26:24 -04:00 committed by GitHub
commit 7417e974f4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 723 additions and 1 deletions

View file

@ -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.

View file

@ -144,6 +144,7 @@
"pages": [
"servers/elicitation",
"servers/sampling",
"servers/completions",
"servers/progress",
"servers/logging",
"servers/pagination",

View 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.

View 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

View file

@ -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,
)

View file

@ -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],

View 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"}