AI agents · · 6 min read
MCP Python SDK: test a read-only FAQ tool before adding AI
Expose a fixed FAQ through a local MCP server, test structured answers and rejected inputs, and keep tool metadata separate from real permissions.
By Sociologix Editorial

Start with the tool contract, then connect a model
A customer-care assistant needs a dependable way to retrieve published answers. Before adding a language model, test a smaller contract: which FAQ keys are available, what a successful response contains, and what happens when the requested answer does not exist.
This AI-assisted editorial guide builds that contract with the official MCP Python SDK. The example runs a local server and a client that calls it through standard input and output. Its two FAQ entries are fictional test data. No model is connected, no customer records are read, and no email or CRM action is implemented.
1. Install a pinned SDK in a scratch environment
We verified mcp 2.3.0 and ran this example with Python 3.13.15 on October 26, 2026. The pinned release requires Python 3.10 or later and is MIT licensed. Plain mcp is sufficient here; the optional CLI extra is unnecessary for this client-driven test. [1]
Version 2 uses MCPServer. Older examples using FastMCP or older result-field names should not be mixed into this program without checking the migration guide. Pinning the package makes the intended API explicit. [2]
Run these PowerShell commands in a new directory. Installation needs internet access. Save the next two blocks as faq_server.py and check_faq.py in that directory before the final command; the client launches the server for you.
mkdir mcp-faq-demo
cd mcp-faq-demo
python -m venv .venv
.\.venv\Scripts\python.exe -m pip install "mcp==2.3.0"
# Save faq_server.py and check_faq.py below first.
.\.venv\Scripts\python.exe check_faq.py2. Expose one fixed lookup function
Save this as faq_server.py. The function accepts a string key of 1–48 characters, then trims spaces and lowercases it for lookup. The SDK derives the input contract from type hints and Pydantic field constraints. The dictionary contains only our two synthetic answers. [3]
The read-only and closed-world annotations describe the intended behavior. They do not remove filesystem access, enforce a database policy or create a sandbox. The implementation is narrow because it only looks in a fixed dictionary; the metadata is not what makes that true. The specification explicitly treats annotations as hints. [7]
from typing import Annotated
from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
from mcp.types import ToolAnnotations
from pydantic import Field
mcp = MCPServer("Local FAQ demo")
# Fictional examples, not approved company policies.
FAQ = {
"websites": {"answer": "Example website service answer.", "source_id": "demo-001"},
"automation": {"answer": "Example automation service answer.", "source_id": "demo-002"},
}
@mcp.tool(annotations=ToolAnnotations(read_only_hint=True, open_world_hint=False))
def get_faq(key: Annotated[str, Field(min_length=1, max_length=48)]) -> dict[str, str]:
"""Look up one exact key in the fixed fictional FAQ."""
normalized = key.strip().lower()
if normalized not in FAQ:
raise ToolError("No published demo answer for that key.")
return {"key": normalized, **FAQ[normalized]}
if __name__ == "__main__":
mcp.run(transport="stdio")
3. Exercise it through an actual MCP client
Save this as check_faq.py. StdioServerParameters points to the same Python interpreter running the test and an explicit sibling server file. Entering the Client context starts the subprocess and connects to it; leaving the context closes the connection. This example does not open a public HTTP endpoint. [4]
The client discovers the advertised tool, calls it and inspects structured_content rather than scraping a text display. A dictionary return is represented as an object in structured output. Our check also requires the expected source_id, so a later implementation change that drops it becomes visible. The fixture ID is a reference label, not evidence that an answer is correct. [5]
import asyncio
import json
import sys
from pathlib import Path
from mcp import Client, StdioServerParameters
def check(condition: bool, label: str) -> None:
if not condition:
raise AssertionError(label)
print("PASS:", label)
async def main() -> None:
params = StdioServerParameters(
command=sys.executable,
args=[str(Path(__file__).with_name("faq_server.py").resolve())],
)
async with Client(params) as client:
tools = (await client.list_tools()).tools
check([tool.name for tool in tools] == ["get_faq"], "only intended tool listed")
check(tools[0].annotations.read_only_hint is True, "read-only hint advertised")
known = await client.call_tool("get_faq", {"key": "websites"})
check(not known.is_error and known.structured_content == {
"key": "websites", "answer": "Example website service answer.",
"source_id": "demo-001",
}, "known answer and source ID")
normalized = await client.call_tool("get_faq", {"key": " WEBSITES "})
check(normalized.structured_content == known.structured_content, "key normalization")
unknown = await client.call_tool("get_faq", {"key": "pricing"})
check(unknown.is_error and unknown.structured_content is None, "unknown key is a tool error")
wrong_type = await client.call_tool("get_faq", {"key": ["websites"]})
check(wrong_type.is_error, "wrong input type rejected")
too_long = await client.call_tool("get_faq", {"key": "x" * 49})
check(too_long.is_error, "oversized key rejected")
again = await client.call_tool("get_faq", {"key": "automation"})
check(not again.is_error and again.structured_content["source_id"] == "demo-002",
"session works after rejected calls")
print(json.dumps({"known": known.structured_content,
"unknown_is_error": unknown.is_error}, indent=2))
if __name__ == "__main__":
asyncio.run(main())
4. Check the observed results and error path
All eight checks passed locally: only get_faq was advertised; its read-only hint was present; websites returned the exact expected answer and demo-001; a padded uppercase key returned the same object; pricing returned a tool error; a list instead of a string was rejected; a 49-character key was rejected; and automation still returned demo-002 after those rejected calls.
The unknown-key response had is_error set to true and no structured result. It did not invent a price or return an apology as if it were a valid FAQ answer. ToolError and argument validation are represented as tool-error results through this client path. That is different from assuming every unsuccessful tool call raises a Python exception. [6]
The deliberate negative cases also produced diagnostic messages on stderr. These were expected test events. Keep ordinary server diagnostics out of the protocol channel, and make your client inspect the error flag before using a result. The observed output establishes this small interface behavior, not how reliably a model will choose a key.
5. Turn the fixture into a business integration deliberately
Replace fictional entries with reviewed public content only after defining who maintains it, which version is published and how obsolete answers are withdrawn. Keep useful source identifiers with the response. A real client can then display the referenced material or route an unanswered question to a person.
This is exact-key lookup, not natural-language search. Model selection, question-to-key mapping, conversational fallback and response quality still need separate evaluation. An MCP connection does not by itself supply any of those behaviors.
A local server runs code with the access granted to its process. Review its source and give it only the data access it needs. If you later add customer-specific records or remote transport, implement authentication and per-customer authorization separately. Keep consequential write operations in distinct tools with explicit approval rules; a read-only hint must never stand in for those controls.
Treat retrieved text as business data rather than instructions to execute more tools. The fixed dictionary and rejected-input cases here do not demonstrate resistance to prompt injection in a complete assistant. Test the host application and any model-mediated decisions before connecting live workflows.
Sources & further reading
- Official MCP Python SDK v2.3.0: requirements, installation and license
- MCP Python SDK migration guide: v1 to v2 API changes
- MCP Python SDK tools: typed parameters and input constraints
- MCP Python SDK client transports: local stdio subprocesses
- MCP Python SDK structured output: dictionary results
- MCP Python SDK handling errors: ToolError and validation behavior
- MCP 2026-07-28 schema: tool annotations are hints
Give your AI assistant a clearer connection to business knowledge
Sociologix can help design tool interfaces, reviewed knowledge sources and approval handoffs for customer-care and operational assistants. Bring one recurring question and the source your team trusts to answer it.
Talk to Sociologix