Tools and MCP¶
Every framework bolting MCP onto an existing app maintains two descriptions of the same endpoint: the route, and the tool. They drift.
In WebCortex the route is the tool. tools/list is a projection of the route
table, and a tool call re-enters the same dispatcher an HTTP request would.
Exposing a tool¶
@app.get("/tickets/{id}/summary", tool=True, scopes=["read"])
def summarize(id: int, style: str = "short") -> dict:
"""Summarise a ticket for a human or an agent."""
return {"id": id, "summary": f"Ticket #{id}"}
That produces:
{
"name": "get_tickets_by_id_summary",
"description": "Summarise a ticket for a human or an agent.",
"inputSchema": {
"type": "object",
"properties": {
"id": {"type": "integer"},
"style": {"type": "string", "default": "short"}
},
"required": ["id"],
"additionalProperties": false
},
"annotations": {"readOnlyHint": true, "idempotentHint": true,
"requiresApproval": false}
}
The schema came from the signature. The description came from the docstring. Nothing was written twice, so nothing can drift.
Connecting a client¶
The MCP endpoint speaks Streamable HTTP JSON-RPC at
/_webcortex/mcp and implements initialize, tools/list, tools/call,
ping, batching, and notifications.
The tool list is filtered per caller¶
This is the part most MCP integrations get wrong.
A caller only sees tools their scopes permit. Listing a tool the caller cannot use invites the model to plan around it and then fail, and it discloses the shape of your privileged surface.
ADMIN sees: list_tickets get_tickets create_tickets update_tickets delete_tickets
READER sees: list_tickets get_tickets
Calls are enforced with the caller's own principal — no scope substitution. An MCP caller has exactly the authority they would have over plain HTTP.
This was a real vulnerability
An earlier version substituted the route's declared scopes for the caller's when dispatching an MCP tool call. That made every scoped tool reachable by any MCP client. It is now a regression test.
Errors are information, not faults¶
A failing tool is a successful protocol exchange reporting an error, so the model can read it and adapt:
{
"content": [{"type": "text", "text": "tool get_tickets failed with 404: not found"}],
"isError": true
}
A transport-level error would just look like the server broke.
Approval-gated tools¶
A tool marked approval="required" cannot be invoked directly over MCP at all:
{
"content": [{"type": "text",
"text": "tool \"create_tickets_purge\" requires human approval and cannot be invoked directly over MCP"}],
"isError": true
}
Honouring the gate only inside the agent loop would leave an obvious way around it. See Agents.
The control plane¶
Mounted under /_webcortex (configurable via control_prefix).
| Endpoint | Purpose |
|---|---|
GET /health |
Liveness. Never requires auth — load balancers cannot present a key |
GET /openapi.json |
OpenAPI 3.1 document |
GET /tools |
Plain tool listing |
GET /routes |
Route table with the engine serving each |
GET /agents · /behaviours · /flows · /contexts |
Declarations |
GET /models |
Model aliases, live providers, priced models |
GET /usage |
The spend ledger, by caller and model |
GET /approvals · POST /approvals/{id} |
Runs waiting on a human, and deciding them |
GET /audit |
Recent agent activity |
GET /security |
Public attack surface |
POST /mcp |
MCP JSON-RPC |
The control plane needs webcortex:admin
Once any authentication is configured, everything except /health requires
the webcortex:admin scope. An open MCP endpoint hands an attacker every
tool in your application.
One declaration, four consumers¶
flowchart LR
R["Route<br/><small>declared once</small>"] --> A["REST endpoint"]
R --> B["OpenAPI operation"]
R --> C["MCP tool"]
R --> D["TypeScript client method"]
Add a route and all four update. There is no second artifact to forget.