From 7417e974f4df594dd7fbab54253b4fcb8b38eab3 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 21 Jul 2026 12:26:24 -0400 Subject: [PATCH] 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 --- docs/development/v4-notes/change-register.mdx | 28 ++ docs/docs.json | 1 + docs/servers/completions.mdx | 177 +++++++++++ fastmcp_slim/fastmcp/server/completions.py | 87 ++++++ .../fastmcp/server/mixins/mcp_operations.py | 60 +++- fastmcp_slim/fastmcp/server/server.py | 85 ++++++ tests/server/test_completions.py | 286 ++++++++++++++++++ 7 files changed, 723 insertions(+), 1 deletion(-) create mode 100644 docs/servers/completions.mdx create mode 100644 fastmcp_slim/fastmcp/server/completions.py create mode 100644 tests/server/test_completions.py diff --git a/docs/development/v4-notes/change-register.mdx b/docs/development/v4-notes/change-register.mdx index ad9f45fe1..a2ba5ce45 100644 --- a/docs/development/v4-notes/change-register.mdx +++ b/docs/development/v4-notes/change-register.mdx @@ -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.` 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. diff --git a/docs/docs.json b/docs/docs.json index 17e2abc3c..55bff75f4 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -144,6 +144,7 @@ "pages": [ "servers/elicitation", "servers/sampling", + "servers/completions", "servers/progress", "servers/logging", "servers/pagination", diff --git a/docs/servers/completions.mdx b/docs/servers/completions.mdx new file mode 100644 index 000000000..e7727b0e9 --- /dev/null +++ b/docs/servers/completions.mdx @@ -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" + + + +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. + + +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. + + +## 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. diff --git a/fastmcp_slim/fastmcp/server/completions.py b/fastmcp_slim/fastmcp/server/completions.py new file mode 100644 index 000000000..a8a49a13d --- /dev/null +++ b/fastmcp_slim/fastmcp/server/completions.py @@ -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 diff --git a/fastmcp_slim/fastmcp/server/mixins/mcp_operations.py b/fastmcp_slim/fastmcp/server/mixins/mcp_operations.py index 174fd3589..f0d245d50 100644 --- a/fastmcp_slim/fastmcp/server/mixins/mcp_operations.py +++ b/fastmcp_slim/fastmcp/server/mixins/mcp_operations.py @@ -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, + ) diff --git a/fastmcp_slim/fastmcp/server/server.py b/fastmcp_slim/fastmcp/server/server.py index 88a7624d9..c66c9c57b 100644 --- a/fastmcp_slim/fastmcp/server/server.py +++ b/fastmcp_slim/fastmcp/server/server.py @@ -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], diff --git a/tests/server/test_completions.py b/tests/server/test_completions.py new file mode 100644 index 000000000..88c0bbceb --- /dev/null +++ b/tests/server/test_completions.py @@ -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"}