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: anexplain_topicprompt, adocs://{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
pytestsuite covering prefix filtering, context dependence, and the empty case. - A
makewrapper with a help screen.
Prerequisites
- macOS 13+ with Homebrew (brew.sh).
- uv 0.5+ —
brew install uv; verifyuv --version. - Xcode Command Line Tools (
xcode-select --install) formake. - 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
fastmcpis the only runtime dependency. The completion types (Completion,PromptReference,ResourceTemplateReference) come from themcppackage 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
DOCSis a dict of dicts. Python preserves insertion order, solanguages()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_docis 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_topicis a normal prompt; its parameterslanguageandtopicare the arguments a client completes.doc_resourceis a template whose{language}and{topic}placeholders are the parameters a client completes._matchesdoes the prefix filter (case-insensitive) and wraps the result.totalis the count;hasMoresignals paging. A single completion response is capped at 100 values by the spec, so a large set setshasMore=Trueand 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. Fortopic, it readscontext.argumentsfor a previously chosenlanguageand narrows the candidates; with no language yet, it offers every topic. ReturningNone(for an unknown argument) yields an empty suggestion list. refis available but unused here because the argument names are unique across the prompt and template. With two prompts that both take acityargument needing different values, you would branch onref.name(for prompts) orref.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
PROMPTandTEMPLATEare the two reference types. APromptReferencecarries the promptname; aResourceTemplateReferencecarries the templateuri(the pattern with{...}, not a filled-in URI).client.complete(ref, argument, context_arguments)is the request. Theargumentdict is{"name": ..., "value": ...};context_argumentssupplies already-chosen values.- The two
topic 't'calls show the context dependency directly: withlanguage=rustthe only match istraits, withlanguage=pythonit istyping.
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 = autoruns the async tests without a marker.tests/__init__.pymakestests/a package so the project root lands onsys.pathandfrom server import mcpresolves.
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_filteringpins the deterministic order (python, rust, go) and the prefix filter.test_topic_is_context_dependentis the core test: the same argument and typed value produce different suggestions depending on the context.test_unknown_argument_has_no_suggestionsconfirms the handler returningNonereaches the client as an empty value list, not an error.test_prompt_and_template_still_resolvechecks 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 := helpmakes a baremakeprint the help screen. Recipe bodies must be tab-indented ormakeerrors.
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. AttributeErroronmcp.types. The server object is namedmcpand shadows themcppackage. 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_argumentsand the handler readscontext.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__.pyso pytest puts the project root onsys.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=Trueand page a large candidate set instead of truncating. - Rank matches (substring or fuzzy) rather than prefix-only, so
syncsurfacesasyncio. - Wire completions to a database or an API so suggestions track live data.