MCP: design a precise, bounded and authorised tool server
An MCP server earns its keep when tools have precise actions, bounded outputs and real authorization. A practical method using a fictional read-only catalogue.
The first question is not “how do I connect this tool to AI?” It is “what decision must the assistant make with this tool’s data?” If an operator already knows how to run a command and read its output, an MCP server adds code, permissions and new ways to fail. It becomes useful when several conversations need the same capability, its arguments can be specified precisely, and we want to inspect the model’s calls instead of asking it to improvise shell commands. The benefit is a stable interface between an intention and an API, not extra intelligence inside the tool.
SEC565 lab 2.3 uses Empire and Cobalt Strike as its integration setting. The course notes describe selecting API routes, giving them useful names and returning results an assistant can handle. That raises a sound architectural question, but our local lab journal still lists the exercise as unfinished. We cannot claim to have driven a C2 agent through MCP. The example below has no target: a fictional, read-only catalogue of analysis sessions. It shows the decisions to make before picking a library.
If the CLI does the job, keep it
MCP separates the host (the application orchestrating the model), the client it creates and the server exposing capabilities. The server does not talk to the model directly; the host decides what the model sees. An MCP tool has a name, description and input schema. The model can discover it and request an invocation. A resource is content the application elects to load; a prompt is a message template selected by the user. That distinction keeps us from turning every file or HTTP route into a “command.” (MCP architecture, Python SDK guide)
A local script run once by an analyst needs none of this. A catalogue queried by several assistants, with consistent filters and output, may benefit. The useful comparison is specific: given one question, does the assistant choose the right function, pass the right arguments and cite the right session? If the CLI does that job more simply and just as reliably, the MCP bridge has not earned its maintenance cost.
Expose list_sessions and get_session
Imagine a store of fictional sessions. Each record has an opaque ID, timestamp, status and short summary. The underlying API can delete and export records, but our server exposes neither operation. We would start with list_sessions(status, cursor, limit) to find IDs and get_session(session_id) to read one record. We can write the contract without an SDK:
list_sessions(status?: "open" | "closed", cursor?: string, limit: 1..20)
-> {items: [{id, started_at, status, summary}], next_cursor: string | null}
get_session(session_id: string)
-> {id, started_at, status, summary, evidence_refs: string[]}
Here session_id names an object, never a filesystem path or part of a shell command. evidence_refs contains references, not full logs. The server validates values and the limit cap before it calls the API. An unknown ID yields “session not found”; an unavailable API yields a recognisable dependency error; failed authorization yields a refusal. Those outcomes mean different things to the model. Hiding all three behind an empty string invites false conclusions.
This contract is an interface sketch; the example below is a small implementation we ran locally. In the current Python SDK, a function decorated with @mcp.tool() gets its schema and description from type annotations and the docstring. The SDK now documents MCPServer in v2, while the SEC565 workbook uses FastMCP: pin the version and imports before copying an example. (Python SDK first steps, v2 changes)
A reproducible server and test
Copy the next block into server.py. It uses only a fictional in-memory list: no connection, C2 or target. The MCPServer and ToolError imports match Python SDK mcp==2.3.0; filtering, the cursor and the limit are implemented in our code, not supplied automatically by MCP. An expected failure raises ToolError so the client receives is_error=True. (SDK error-handling guide)
from typing import Literal
from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
mcp = MCPServer("Session catalogue")
SESSIONS = [
{"id": "s-101", "started_at": "2026-10-20T08:00:00Z", "status": "open", "summary": "Login review", "evidence_refs": ["log-11"]},
{"id": "s-102", "started_at": "2026-10-20T09:00:00Z", "status": "closed", "summary": "Patch check", "evidence_refs": ["log-12"]},
{"id": "s-103", "started_at": "2026-10-20T10:00:00Z", "status": "open", "summary": "API review", "evidence_refs": ["log-13"]},
]
@mcp.tool()
def list_sessions(
status: Literal["open", "closed"] | None = None,
cursor: str | None = None,
limit: int = 2,
) -> dict:
"""List fictional sessions; return IDs and a cursor, never full logs."""
if not 1 <= limit <= 20:
raise ToolError("limit must be between 1 and 20")
rows = [r for r in SESSIONS if status is None or r["status"] == status]
ids = [r["id"] for r in rows]
if cursor is not None and cursor not in ids:
raise ToolError("unknown cursor")
start = ids.index(cursor) + 1 if cursor is not None else 0
page = rows[start:start + limit]
return {
"items": [{k: r[k] for k in ("id", "started_at", "status", "summary")} for r in page],
"next_cursor": page[-1]["id"] if start + limit < len(rows) else None,
}
@mcp.tool()
def get_session(session_id: str) -> dict:
"""Read one fictional session by opaque ID."""
for row in SESSIONS:
if row["id"] == session_id:
return row
raise ToolError("session not found")
Copy the next block into test_server.py in the same directory. It calls the server in memory and opens no port. The command checks both pages, the detail response and three refusals. The “Tool failed” lines are the expected negative cases. (SDK in-memory testing)
import anyio
import json
from mcp import Client
from server import mcp
async def main():
async with Client(mcp, raise_exceptions=True) as client:
first = await client.call_tool("list_sessions", {"status": "open", "limit": 1})
first_data = json.loads(first.content[0].text)
assert first_data["items"][0]["id"] == "s-101"
cursor = first_data["next_cursor"]
second = await client.call_tool("list_sessions", {"status": "open", "cursor": cursor, "limit": 1})
second_data = json.loads(second.content[0].text)
assert second_data["items"][0]["id"] == "s-103"
assert second_data["next_cursor"] is None
detail = await client.call_tool("get_session", {"session_id": "s-103"})
assert json.loads(detail.content[0].text)["evidence_refs"] == ["log-13"]
missing = await client.call_tool("get_session", {"session_id": "s-999"})
assert missing.is_error is True
bad_limit = await client.call_tool("list_sessions", {"limit": 21})
assert bad_limit.is_error is True
bad_cursor = await client.call_tool("list_sessions", {"cursor": "s-999"})
assert bad_cursor.is_error is True
print("PASS: pagination, detail, missing session, bad limit, bad cursor")
anyio.run(main)
uv run --no-project --with mcp==2.3.0 python test_server.py
We ran this command locally: it exited with PASS: pagination, detail, missing session, bad limit, bad cursor. That proves the behaviour of this small example, not its usefulness for a C2 agent or its safety in a remote integration. To make the server visible in a host, the SDK also provides uv run mcp dev server.py and its Inspector; connecting a chosen host would be another step to test. (SDK first steps)
Edge cases are part of the function
A first implementation should use a deterministic fake data source, then test an empty result, another page, a missing ID, a timeout and a multi-megabyte response. Without pagination, list_sessions can fill the model’s context with irrelevant data; without a timeout, a stuck call hides the cause from the assistant. Return a bounded number of entries, a cursor and IDs for fetching detail. Anthropic’s tool-design guidance likewise stresses choosing useful tools and shaping results for the available context budget.
Then test two separate layers. Server tests, without a model, check that malformed inputs are rejected and outputs match the contract. The Python SDK supports an in-memory client for this and an Inspector that shows the exposed tools. Assistant tests give it realistic tasks: “find the open session mentioning this event, then give its reference.” Read the raw transcript: tool choice, arguments, pagination, error handling and cited reference. A polished final answer is worthless if it came from the wrong session. (SDK testing guide)
A schema does not grant authorization
Locally, stdio avoids opening a network service, but the process still has the launching user’s rights. For a remote server, client authentication, permissions on the downstream API and identity separation become explicit design choices. A tool described as “read-only” but connected as an administrator is not constrained by its prose. MCP’s security guidance discusses token passthrough, confused-deputy problems, SSRF and scope minimisation. (MCP security best practices)
For a real offensive integration, I would begin with read-only access using an account limited in the downstream system. A mutation would require approval outside the model for the exact action, target and effects. A host may show a confirmation prompt, but we must also check what the server and API reject themselves when that confirmation is absent. The specification recommends giving a human the ability to deny tool calls; that recommendation does not constitute universal access control. (MCP tools specification)
The release test is therefore not “the AI completed a demo.” It is whether the contract survives bad inputs, the model picks correctly between two tools, responses remain short and verifiable, and no alternative route grants more rights than intended. MCP helps make those questions visible. We still have to answer them.
#rootzero
A question about your exposure?
Write to us. Someone from the technical team will answer, with sources.