An equipment-request assistant needs to answer a small question: what happened to the request for a projector? We can expose that lookup as one MCP tool and test the complete call before connecting an AI assistant.
This walkthrough uses synthetic records and a local Python process. It implements a read-only lookup. Authentication, remote hosting, persistent storage, and changes to requests are outside this example's scope.
Install the SDK in an isolated environment
The example was exercised with Python 3.12 and the official MCP Python SDK 2.2.0. The SDK repository documents the supported release lines. SDK versions change; the version below fixes the API used in this walkthrough.
On macOS or Linux, create a working folder and run:
python3 -m venv .venv
source .venv/bin/activate
python -m pip install 'mcp==2.2.0'
On Windows, activate the environment with .venv\Scripts\Activate.ps1 in PowerShell before the installation command.
Expose one operation
Save this as mcp_server.py:
"""Synthetic, local-only MCP tutorial. No credentials or external services."""
from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
mcp = MCPServer("Equipment request demo")
REQUESTS = {
"demo-1": {"id": "demo-1", "item": "Projector", "status": "awaiting_review"},
"demo-2": {"id": "demo-2", "item": "Microphone", "status": "approved"},
}
@mcp.tool()
def get_request(request_id: str) -> dict[str, str]:
"""Read one synthetic equipment request by ID. Does not change its status."""
if request_id not in REQUESTS:
raise ToolError("Unknown demo request ID")
return dict(REQUESTS[request_id])
if __name__ == "__main__":
mcp.run(transport="stdio")
The function's input type describes the required identifier. The body returns a copy of the selected record. Unknown identifiers produce an expected tool error. There is no operation that approves a request or changes the fixture.
The stdio transport lets a client launch this process and communicate through its input and output streams. Keep diagnostic output away from stdout because that stream carries protocol messages.
Call it through an MCP client
Save the following beside the server as test_mcp.py. The client starts the server subprocess itself, so you do not need to run a separate terminal for it.
import asyncio
import sys
from pathlib import Path
from mcp import Client
from mcp.client.stdio import StdioServerParameters
async def main():
server = StdioServerParameters(command=sys.executable,
args=[str(Path(__file__).with_name("mcp_server.py"))])
async with Client(server) as client:
tools = await client.list_tools()
assert [tool.name for tool in tools.tools] == ["get_request"]
result = await client.call_tool("get_request", {"request_id": "demo-1"})
assert not result.is_error
assert result.structured_content == {
"id": "demo-1", "item": "Projector", "status": "awaiting_review"}
missing = await client.call_tool("get_request", {"request_id": "missing"})
assert missing.is_error
invalid = await client.call_tool("get_request", {})
assert invalid.is_error
repeat = await client.call_tool("get_request", {"request_id": "demo-1"})
assert repeat.structured_content == result.structured_content
print("PASS: stdio connection, tool discovery, exact response, unknown ID, missing argument, unchanged repeat read")
print("Negotiated protocol:", client.protocol_version)
print("Result:", result.structured_content)
if __name__ == "__main__":
asyncio.run(main())
Run the checks:
python test_mcp.py
The successful run prints PASS, the negotiated protocol version, and the projector request with status awaiting_review. The assertions verify discovery, the exact returned record, unknown-ID handling, a missing required argument, and an unchanged repeated read. Run Python normally; its -O option disables assertions.
This proves the local SDK-to-SDK path shown here. It does not establish compatibility with every assistant or provide evidence of a deployed service. To test your intended assistant next, use its documented local-MCP configuration with the absolute paths to this environment's Python executable and the server file.
Read the result and the failed cases
For demo-1, the useful returned fields are id, item, and status. The test compares all of them with the fixture. That catches a server which returns a plausible status for the wrong request as well as a server which returns the right identifier with incomplete information.
The unknown-ID call should set the tool-error flag. It should not return an empty successful object that the assistant might interpret as a real record. The call without request_id tests the input contract separately. Both failures are expected, so diagnostic error messages during this test do not mean the successful lookup failed.
Finally, the repeated lookup checks that this read leaves the visible result unchanged. It is a small check for this fixture, not a general proof that arbitrary tool code has no side effects. The next assistant-level test should ask for the same identifier and inspect whether the assistant selects this tool and accurately reports its output. That evaluates a different part of the workflow from the deterministic client test.
Give a real workflow its own boundaries
Replacing the fixture with business data adds decisions. The service must identify the user, limit the records that user may read, and handle storage failures. Adding a write also needs input validation, authorization, duplicate handling, and a way to establish whether the action completed.
On OBTO, application tools can be exposed through its MCP surface. That is a separate implementation path from deploying this Python process. Use the platform's current handler contract and explicit application scope when building there.
Keep the lookup small while connecting real data. Add an approval operation only after you can demonstrate which identity may use it and which requests it must refuse.