Blog··Agents

Every API route is already an agent tool

The interesting question about AI agents and internal software is not whether the model is good enough. It is what happens the third time someone changes a route.

ROUTE DECLARATIONDERIVED MCP TOOLPOST /documentsforms:createGET /documentsdocuments:readGET /formsforms:readOpenAPI schemapost_api_v1_…same permissionget_api_v1_…same permissionget_api_v1_…same permissionnothing hand-written · nothing to drift

The usual way to give an AI agent access to an application is to write tool definitions for it. You pick the twenty operations that seem useful, describe each one in JSON, wire each description to the function that performs it, and ship. It demos beautifully.

Then someone renames a field. The route changes, the tool definition does not, and the agent keeps confidently calling an endpoint with an argument that no longer exists. Nothing fails loudly — the tool definition is not code, so no compiler objects, no test covers it, and the failure surfaces as the model "being unreliable". You have built a second API surface with none of the guardrails of the first one.

Whity does not maintain tool definitions, because it does not have any. The agent surface is derived from the same schema the HTTP API is described by, and the derivation happens at request time.

The catalogue is a projection, not a copy

Whity's router generates an OpenAPI specification from typed request and response contracts — 365 documented operations at the time of writing. That specification is not just published for client generation; it is the input to the MCP server.

When an agent calls tools/list, ToolDeriver walks the route declarations and turns each one that carries a schema into a tool:

  • the tool's name is the route's operationId
  • the tool's input schema is the route's request schema
  • the tool's description comes from the route's own summary

So POST /api/v1/documents is reachable as post_api_v1_documents, taking exactly the body the HTTP route takes. Nothing is written twice, which means nothing can disagree. Add a route and a tool appears. Change its request schema and the tool's input schema changes with it. Delete the route and the tool is gone on the next listing.

POST /mcpJSON-RPC 2.0
{
  "jsonrpc": "2.0",
  "id": 7,
  "method": "tools/call",
  "params": {
    "name": "post_api_v1_forms",
    "arguments": {
      "form_key": "incident-report",
      "name": { "ar": "بلاغ حادثة", "en": "Incident report" }
    }
  }
}

The part that actually matters: authorization

A derived catalogue is a nice property. It would be a dangerous one if the derivation quietly dropped the access rules along the way — an agent that can call every operation it can see is a privilege escalation with a friendly interface.

Route declarations already carry requiredPermission andrequiredRole. ToolDeriver::buildAccessMap() reads those at derivation time and keeps a map of tool name to the grants it needs. Two separate things then happen with it, and the distinction is deliberate:

  1. tools/list is filtered, softly

    A caller only sees tools their grants allow. A missing or invalid token never throws — it just narrows the visible set to the tools that require nothing. An agent is not told about the existence of capabilities it cannot use.

  2. tools/call is enforced, hard

    The bearer token is re-validated to obtain the principal, and the matched route's access controls are checked through RoleChecker — the same component the HTTP RbacMiddleware uses. On denial you get aFORBIDDEN error, exactly as the HTTP surface would have produced a structured 403.

That shared component is the whole argument. MCP authorization cannot drift from HTTP authorization because there is no second implementation of it to drift. A permission change lands in one place and both surfaces obey it. The check also reads from the live matched route rather than only the declaration cache, so a plugin that changes its route's requirements after boot is covered on the next request.

Hiding is not protection, and the split says so. Filtering the list is a usability decision — it keeps an agent's context window free of tools it will only be refused. The security decision is the one at call time, and it does not trust the listing in any way.

What this feels like in use

You issue a long-lived token scoped to tools:call through the management API, hand it to your client, and point it at POST /mcp. The agent discovers what it can do by asking, and what it can do is precisely what the token's grants allow — the same answer a human with those grants would get from the web UI, because the web UI is another client of the same API.

The practical effect is that "give the ops team's assistant read access to documents but not to user administration" is a role assignment, not an integration project. There is no separate agent permission model to design, audit and keep in sync, which is the part of this work that usually rots.

Why not a workflow engine

Embedding a general automation tool was considered — n8n specifically — and deliberately deferred. The reasoning was that it would create a second integration surface with its own credentials, its own permission story and its own drift, to sit alongside an API that was already capable of everything the workflow engine would call.

One surface, derived from the schema, gated by the existing permission mesh. That is less exciting than a node editor, and it is considerably less to keep true.

The honest limitations

A derived catalogue inherits the API's shape, including the awkward parts. A route designed for a form submission is not automatically a good tool for a model — parameter names written for a TypeScript client are the parameter names the agent sees, and an operation that requires three prior calls to be useful still requires them. Good tools and good REST endpoints overlap heavily, but not perfectly.

There is no standing event stream either: GET /mcp returns 405, because a held connection would occupy one of eight FrankenPHP workers for its lifetime. Change notifications ride on POST responses instead.

What you get in exchange is that the catalogue is never stale, never partial, and never more permissive than the API it projects. For an internal platform where the cost of a confidently wrong agent action is measured in someone's real data, that trade has been worth making.

Frequently asked

How does Whity expose its API to AI agents?

Whity runs an MCP (Model Context Protocol) server at POST /mcp speaking JSON-RPC 2.0. Its tool catalogue is derived at tools/list time from the OpenAPI specification the router already generates: each route with a request/response schema becomes a tool whose name is the route's operationId and whose input schema is the route's request schema. Plugin routes are included as soon as the plugin loads.

Can an AI agent bypass permissions in Whity?

No. tools/call re-validates the bearer token and enforces the matched route's requiredPermission or requiredRole through RoleChecker — the same component the HTTP RbacMiddleware uses, so MCP authorization cannot diverge from HTTP authorization. tools/list is additionally filtered so a caller only sees the tools their grants allow.

Do I have to write MCP tool definitions by hand for Whity?

No. There are no hand-written tool definitions to maintain. Adding a route with a schema adds a tool; changing the route's request schema changes the tool's input schema; removing the route removes the tool.

Connect a client with theMCP connection guide, or read the architecture decision behind it inADR 0006. The platform overview lives on the platform page.