When a user fills in a prompt argument or a resource URI in an MCP client, the client can offer autocomplete, the same way a shell completes a path. That only works if the server answers a completion/complete request with suggestions scoped to what the user has typed. Without it, the user guesses at valid values and finds out they were wrong only when the call fails.

This tutorial adds completions to a small documentation server built with FastMCP. One handler serves suggestions for a prompt’s arguments and a resource template’s parameters, filters by the typed prefix, reads from the same data the server actually serves, and narrows one argument based on another (topics depend on the chosen language). The stack is Mac-native: uv, make, and pytest.

What completions cover

The MCP completion capability applies to prompt arguments and resource template parameters — not tool arguments. A completion request carries three things, and your handler returns a list of candidate values:

  • A reference: which prompt (PromptReference) or template (ResourceTemplateReference) is being filled in.
  • An argument: its name and the partial value typed so far.
  • A context: arguments already resolved, so one argument’s suggestions can depend on another.

FastMCP does not expose a high-level decorator for this. The handler is registered on the underlying low-level server through mcp._mcp_server, which is the one place this tutorial reaches past the FastMCP surface.

What you will build

  • docs_data.py: an ordered dataset of languages and topics — the live source completions read from.
  • server.py: an explain_topic prompt, a docs://{language}/{topic} resource template, and one completion handler for both.
  • client.py: an in-memory client that requests completions, including a context-dependent one.
  • A pytest suite covering prefix filtering, context dependence, and the empty case.
  • A make wrapper with a help screen.

Prerequisites

  • macOS 13+ with Homebrew (brew.sh).
  • uv 0.5+brew install uv; verify uv --version.
  • Xcode Command Line Tools (xcode-select --install) for make.
  • Familiarity with FastMCP prompts, resource templates, and the in-memory Client (see Build an MCP Server with FastMCP and Build a Dynamic MCP Server).

Step 1: Add project hygiene

Create the file

mkdir -p argument-completions-mcp-server-macos
cd argument-completions-mcp-server-macos
touch .gitignore

Add the code: .gitignore

# Python
__pycache__/
*.py[cod]
.venv/
.uv/
.pytest_cache/
.ruff_cache/
.mypy_cache/
*.log
# OS / editor noise
.DS_Store

Detailed breakdown

  • Standard Python ignores plus .DS_Store, created first so the virtualenv and caches never get committed.

Step 2: Initialize the project with uv

Create the files

uv init --name macmcp --no-workspace
rm -f main.py hello.py
uv add fastmcp
uv add --dev pytest pytest-asyncio

Add the code: pyproject.toml

[project]
name = "macmcp"
version = "0.1.0"
description = "An MCP docs server with prompt and resource-template completions"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
    "fastmcp>=3.4.4",
]

[dependency-groups]
dev = [
    "pytest>=9.1.1",
    "pytest-asyncio>=1.4.0",
]

Detailed breakdown

  • fastmcp is the only runtime dependency. The completion types (Completion, PromptReference, ResourceTemplateReference) come from the mcp package it installs.

Step 3: The data source completions read from

Completions are only useful if they reflect real, servable values. This module is that source: the completion handler and the resource both read it, so a suggested language/topic pair is always one the server can actually return.

Create the file

touch docs_data.py

Add the code: docs_data.py

"""The live data source that completions are wired to.

The completion handler reads these same functions, so the suggestions a client
sees always match the documents the server can actually serve. Nothing here
imports MCP.
"""

# Ordered so completion results are deterministic.
DOCS: dict[str, dict[str, str]] = {
    "python": {
        "asyncio": "Cooperative concurrency with async/await.",
        "dataclasses": "Boilerplate-free classes with @dataclass.",
        "typing": "Type hints and the typing module.",
    },
    "rust": {
        "ownership": "Each value has a single owner.",
        "traits": "Shared behavior across types.",
        "lifetimes": "How long references stay valid.",
    },
    "go": {
        "goroutines": "Lightweight concurrent functions.",
        "channels": "Typed pipes between goroutines.",
        "interfaces": "Implicitly satisfied method sets.",
    },
}


def languages() -> list[str]:
    return list(DOCS)


def topics(language: str) -> list[str]:
    return list(DOCS.get(language, {}))


def all_topics() -> list[str]:
    return [t for topics in DOCS.values() for t in topics]


def get_doc(language: str, topic: str) -> str | None:
    return DOCS.get(language, {}).get(topic)

Detailed breakdown

  • DOCS is a dict of dicts. Python preserves insertion order, so languages() returns ["python", "rust", "go"] every time, which keeps the completion output deterministic for the demo and tests.
  • topics(language) returns one language’s topics; all_topics() flattens every language’s topics for the case where no language is chosen yet.
  • get_doc is what the resource reads. The completion handler and the resource share this module, so suggestions and served content cannot drift apart.

Step 4: The server, the template, and the completion handler

The server has a prompt and a resource template that share two argument names, language and topic. A single completion handler serves both: it dispatches on the argument name and consults the context to make topic depend on language.

Create the file

touch server.py

Add the code: server.py

"""An MCP docs server with argument completions.

Completions are the "autocomplete" of MCP. When a user fills in a **prompt
argument** or a **resource template parameter**, the client can ask the server
for suggestions given what has been typed so far. (Completions apply to prompts
and resource templates only — not tool arguments.)

FastMCP has no high-level decorator for this, so the handler is registered on
the underlying low-level server via `mcp._mcp_server.completion()`. One handler
serves every prompt and template; it dispatches on the argument name and uses
the completion context for dependent arguments (topic depends on language).

Run over stdio with `python server.py`, or drive it with client.py.
"""

from fastmcp import FastMCP
from fastmcp.exceptions import ToolError
from mcp.types import (
    Completion,
    PromptReference,
    ResourceTemplateReference,
)

import docs_data

mcp = FastMCP("docs")


@mcp.prompt
def explain_topic(language: str, topic: str) -> str:
    """Ask for an explanation of a topic in a language."""
    return f"Explain {topic} in {language} for an experienced engineer."


@mcp.resource("docs://{language}/{topic}")
def doc_resource(language: str, topic: str) -> str:
    """One documentation snippet, addressed by language and topic."""
    text = docs_data.get_doc(language, topic)
    if text is None:
        raise ToolError(f"No doc for {language}/{topic}.")
    return text


def _matches(candidates: list[str], value: str) -> Completion:
    """Filter candidates by prefix and wrap them as a Completion."""
    hits = [c for c in candidates if c.lower().startswith(value.lower())]
    # `total` and `hasMore` let a client show "N matches" and page large sets.
    return Completion(values=hits, total=len(hits), hasMore=False)


@mcp._mcp_server.completion()
async def complete(ref, argument, context) -> Completion | None:
    """Suggest values for a prompt/template argument.

    - ref:      which prompt (PromptReference) or template
                (ResourceTemplateReference) is being filled in.
    - argument: the argument being typed, with `.name` and the partial `.value`.
    - context:  arguments already chosen, used for dependent completions.
    """
    if argument.name == "language":
        return _matches(docs_data.languages(), argument.value)

    if argument.name == "topic":
        # Narrow topics to the chosen language, if one was picked already.
        chosen = (context.arguments or {}).get("language") if context else None
        candidates = docs_data.topics(chosen) if chosen else docs_data.all_topics()
        return _matches(candidates, argument.value)

    return None  # no suggestions for this argument


if __name__ == "__main__":
    mcp.run()

Detailed breakdown

  • explain_topic is a normal prompt; its parameters language and topic are the arguments a client completes. doc_resource is a template whose {language} and {topic} placeholders are the parameters a client completes.
  • _matches does the prefix filter (case-insensitive) and wraps the result. total is the count; hasMore signals paging. A single completion response is capped at 100 values by the spec, so a large set sets hasMore=True and returns the first page.
  • @mcp._mcp_server.completion() registers the handler on the low-level server. FastMCP has no decorator of its own for completions, and registering the handler is also what makes the server advertise the completions capability at initialization.
  • The handler dispatches on argument.name. For topic, it reads context.arguments for a previously chosen language and narrows the candidates; with no language yet, it offers every topic. Returning None (for an unknown argument) yields an empty suggestion list.
  • ref is available but unused here because the argument names are unique across the prompt and template. With two prompts that both take a city argument needing different values, you would branch on ref.name (for prompts) or ref.uri (for templates).

Step 5: Request completions from a client

Create the file

touch client.py

Add the code: client.py

"""Drive the docs server in-memory and request completions.

A real client calls `completion/complete` as the user types. Here we call it
directly to show prefix filtering, resource-template completion, and a
context-dependent argument (topic narrowed by the chosen language).
"""

import asyncio

from fastmcp import Client
from mcp.types import PromptReference, ResourceTemplateReference

from server import mcp

PROMPT = PromptReference(type="ref/prompt", name="explain_topic")
TEMPLATE = ResourceTemplateReference(type="ref/resource", uri="docs://{language}/{topic}")


async def main() -> None:
    async with Client(mcp) as client:
        caps = client.initialize_result.capabilities.completions
        print("completions capability advertised:", caps is not None)

        # Prompt argument: complete `language` from what was typed.
        r = await client.complete(PROMPT, {"name": "language", "value": "r"})
        print("\nlanguage 'r' ->", r.values, f"(total={r.total})")

        # Prompt argument `topic` with no language chosen: all topics.
        r = await client.complete(PROMPT, {"name": "topic", "value": "i"})
        print("topic 'i' (no language) ->", r.values)

        # Same argument, now context-dependent on the chosen language.
        r = await client.complete(
            PROMPT, {"name": "topic", "value": "t"}, context_arguments={"language": "rust"}
        )
        print("topic 't' + language=rust ->", r.values)
        r = await client.complete(
            PROMPT, {"name": "topic", "value": "t"}, context_arguments={"language": "python"}
        )
        print("topic 't' + language=python ->", r.values)

        # Resource template parameter uses the same handler.
        r = await client.complete(TEMPLATE, {"name": "language", "value": "p"})
        print("\ntemplate language 'p' ->", r.values)


if __name__ == "__main__":
    asyncio.run(main())

Detailed breakdown

  • PROMPT and TEMPLATE are the two reference types. A PromptReference carries the prompt name; a ResourceTemplateReference carries the template uri (the pattern with {...}, not a filled-in URI).
  • client.complete(ref, argument, context_arguments) is the request. The argument dict is {"name": ..., "value": ...}; context_arguments supplies already-chosen values.
  • The two topic 't' calls show the context dependency directly: with language=rust the only match is traits, with language=python it is typing.

Run it:

uv run python client.py

Expected output:

completions capability advertised: True

language 'r' -> ['rust'] (total=1)
topic 'i' (no language) -> ['interfaces']
topic 't' + language=rust -> ['traits']
topic 't' + language=python -> ['typing']

template language 'p' -> ['python']

Step 6: Test the completions

Create the files

mkdir -p tests
touch tests/__init__.py
touch pytest.ini
touch tests/test_server.py

Add the code: pytest.ini

[pytest]
asyncio_mode = auto
filterwarnings =
    ignore::DeprecationWarning

Detailed breakdown

  • asyncio_mode = auto runs the async tests without a marker.
  • tests/__init__.py makes tests/ a package so the project root lands on sys.path and from server import mcp resolves.

Add the code: tests/test_server.py

"""Tests for the docs server's argument completions.

Covers the capability advertisement, prefix filtering for prompt and template
arguments, the context-dependent `topic` argument, an argument with no
suggestions, and that the prompt and template themselves still resolve.
"""

from fastmcp import Client
from mcp.types import PromptReference, ResourceTemplateReference

from server import mcp

PROMPT = PromptReference(type="ref/prompt", name="explain_topic")
TEMPLATE = ResourceTemplateReference(type="ref/resource", uri="docs://{language}/{topic}")


async def test_completions_capability_is_advertised():
    async with Client(mcp) as client:
        assert client.initialize_result.capabilities.completions is not None


async def test_language_prefix_filtering():
    async with Client(mcp) as client:
        empty = await client.complete(PROMPT, {"name": "language", "value": ""})
        assert empty.values == ["python", "rust", "go"]
        assert empty.total == 3
        r = await client.complete(PROMPT, {"name": "language", "value": "r"})
        assert r.values == ["rust"]


async def test_topic_without_context_spans_all_languages():
    async with Client(mcp) as client:
        r = await client.complete(PROMPT, {"name": "topic", "value": "i"})
        assert r.values == ["interfaces"]  # only topic starting with "i"


async def test_topic_is_context_dependent():
    async with Client(mcp) as client:
        rust = await client.complete(
            PROMPT, {"name": "topic", "value": "t"}, context_arguments={"language": "rust"}
        )
        assert rust.values == ["traits"]
        python = await client.complete(
            PROMPT, {"name": "topic", "value": "t"}, context_arguments={"language": "python"}
        )
        assert python.values == ["typing"]


async def test_template_uses_the_same_handler():
    async with Client(mcp) as client:
        r = await client.complete(TEMPLATE, {"name": "language", "value": "g"})
        assert r.values == ["go"]


async def test_unknown_argument_has_no_suggestions():
    async with Client(mcp) as client:
        r = await client.complete(PROMPT, {"name": "nonsense", "value": "x"})
        assert r.values == []


async def test_prompt_and_template_still_resolve():
    async with Client(mcp) as client:
        prompt = await client.get_prompt("explain_topic", {"language": "go", "topic": "channels"})
        assert "channels" in prompt.messages[0].content.text
        res = await client.read_resource("docs://python/asyncio")
        assert "async/await" in res[0].text

Detailed breakdown

  • test_language_prefix_filtering pins the deterministic order (python, rust, go) and the prefix filter.
  • test_topic_is_context_dependent is the core test: the same argument and typed value produce different suggestions depending on the context.
  • test_unknown_argument_has_no_suggestions confirms the handler returning None reaches the client as an empty value list, not an error.
  • test_prompt_and_template_still_resolve checks that adding completions did not break the prompt render or the resource read.

Step 7: Wrap it in a Makefile

Create the file

touch Makefile

Add the code: Makefile

.DEFAULT_GOAL := help

.PHONY: help install demo serve test clean

help:  ## Show this help screen
	@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
		| awk 'BEGIN {FS = ":.*?## "}; {printf "  \033[36m%-10s\033[0m %s\n", $$1, $$2}'

install:  ## Sync runtime and dev dependencies
	uv sync

demo:  ## Run the in-memory client and request completions
	uv run python client.py

serve:  ## Run the server over stdio (for a real MCP client)
	uv run python server.py

test:  ## Run the test suite
	uv run pytest -q

clean:  ## Remove caches
	rm -rf .pytest_cache __pycache__ tests/__pycache__

Detailed breakdown

  • .DEFAULT_GOAL := help makes a bare make print the help screen. Recipe bodies must be tab-indented or make errors.

Step 8: Run everything

make          # prints the help screen
make demo     # runs client.py (output shown in Step 5)
make test     # runs the suite

The suite passes:

.......                                                                  [100%]
7 passed in 0.31s

Troubleshooting

  • The client never gets suggestions. Confirm the handler is registered with @mcp._mcp_server.completion(). Registering it is also what advertises the capability; without a handler, the server reports no completions support and a client will not ask.
  • AttributeError on mcp.types. The server object is named mcp and shadows the mcp package. Import the types by name: from mcp.types import Completion, PromptReference, ResourceTemplateReference.
  • Completions ignore the other field. A dependent argument only narrows if the client sends context_arguments and the handler reads context.arguments. A client that does not pass context gets the unfiltered list, so handle both.
  • You wanted to complete a tool argument. The MCP completion capability covers prompts and resource templates only. For tools, constrain the input with an enum in the tool’s input schema so the client can offer the choices from the schema.
  • Only some values come back for a big list. A completion response returns at most 100 values. Return the first page and set hasMore=True; refine by returning fewer, more relevant matches as the user types more.
  • ModuleNotFoundError: No module named 'server' under pytest. tests/ needs an __init__.py so pytest puts the project root on sys.path.

Recap

Completions turn a prompt argument or a resource URI from a guess into a menu. One handler, registered on the low-level server, served both the explain_topic prompt and the docs://{language}/{topic} template: it filtered by the typed prefix, read from the same data the server serves, and narrowed topic by the language already chosen. Registering the handler is what advertises the capability, so a client knows to ask as the user types.

Next improvements:

  • Return hasMore=True and page a large candidate set instead of truncating.
  • Rank matches (substring or fuzzy) rather than prefix-only, so sync surfaces asyncio.
  • Wire completions to a database or an API so suggestions track live data.