III The API

Let an AI Assistant Call the API

A customer asks their AI assistant where their order is, and the assistant looks it up in the shop. A language model acting for a user calls tools. The application’s routes are served as tools through the Model Context Protocol, from the same declarations that serve HTTP: publish a method, describe it, and a model can do what the user it acts for can do, under the same access rules.

§A first tool

Turn the endpoint on in the Context and give the server a name. mcp is added to exposition, beside the authorities that toa create wrote:

# context.toa.yaml
exposition:
  authorities:
    local: localhost:8000
  mcp:
    name: Store

Publish a method as a tool and say what it is for. The routes are those of store.orders, as Expose It over HTTP declares them; the GET of one order is written out to take the two directives:

# components/store.orders/manifest.toa.yaml
exposition:
  /:
    anonymous: true
    io:output: true
    GET: enumerate
    POST: create
    /:id:
      io:output: [id, status, total]
      GET:
        mcp:tool: true
        help:method:
          title: An order
          description: The status and total of one order, by its id.
        endpoint: observe
      PUT: approve

The server is at POST /.mcp of the application’s host. A client that connects to https://api.store.example/.mcp lists one tool, store.orders._id.GET, and can call it.

Without the mcp annotation nothing answers at /.mcp. The path is fixed.

§Try it

For a first look without setting up OAuth, make the endpoint anonymous in the local environment only. The route is anonymous too, by the anonymous: true on /:

# context.toa.yaml
exposition:
  mcp:
    name: Store
    anonymous@local: true
$ npm run restart

Point a client at http://localhost:8000/.mcp; the discovery page shows the address to give it. Or ask as a client would, in a scenario. It needs one step the generated features/steps/http.ts does not have; add it there:

// features/steps/http.ts
Then('the response does not contain:', function (this: World, text: string) {
  this.agent.responseExcludes(text)
})
# features/tools.feature
Feature: Tools

  Scenario: The order lookup is a tool, and nothing else is
    When the request is sent:
      """
      POST /store/orders/ HTTP/1.1
      content-type: application/yaml
      accept: application/yaml

      customer: b70a7dbca6b14a2eaac8a9eb4b2ff4db
      items: []
      total: 4200
      """
    Then the response is received:
      """
      201 Created

      id: ${{ order.id }}
      """
    When the request is sent:
      """
      POST /.mcp HTTP/1.1
      accept: application/yaml
      content-type: application/json

      {"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}
      """
    Then the response is received:
      """
      200 OK

      - name: store.orders._id.GET
      """
    And the response does not contain:
      """
      store.orders.POST
      """
    When the request is sent:
      """
      POST /.mcp HTTP/1.1
      accept: application/yaml
      content-type: application/json

      {"jsonrpc": "2.0", "id": 2, "method": "tools/call",
       "params": {"name": "store.orders._id.GET", "arguments": {"id": "${{ order.id }}"}}}
      """
    Then the response is received:
      """
      200 OK

      structuredContent:
        id: ${{ order.id }}
        status: pending
      """

A scenario starts the components itself and sends its requests to the gateway, which is part of sys; see Write a Test:

$ npm run env
$ npm run sys
$ npm run features

§Publishing methods

Nothing is a tool unless it says so. A tree holds everything an application serves — sign-in endpoints, uploads, internal machinery — and a model chooses among tools by reading their descriptions. A short list of well-described tools serves it better than every route there is.

mcp:tool: true publishes a method. Like every directive it is inherited, and the nearest declaration wins, so a node can publish a subtree and one method can opt out:

exposition:
  /:
    mcp:tool: true
    io:output: [id, status, total]
    GET:
      help:method: Orders, newest first.
      endpoint: enumerate
    /:id:
      GET:
        help:method: One order, by its id.
        endpoint: observe
      PUT:
        mcp:tool: false
        endpoint: approve

§Describing a tool

mcp:tool only says that the method is published. What the tool is called and what it is for comes from help:method, the same declaration that describes the method to people:

      help:method:
        title: Pending orders
        description: Orders waiting for approval, oldest first. Approve one with its id.
  • title is what a person sees where a client lists the tools.
  • description is what the model reads to decide whether to call it.

Always declare help:method beside mcp:tool. A tool whose purpose a model cannot read is one it cannot choose. Write descriptions for a reader who knows nothing about the application: say what comes back, what the identifiers are, and what to call next.

Describe parameters the same way, with help:route and help:query.

§Names

A tool’s name is the name of its procedure: store.orders.GET, store.orders._id.GET. A route that has no such name cannot be a tool, and declaring mcp:tool on it is refused when the manifest is read.

§What a tool takes

A tool’s input schema is derived from the method:

  • a route variable, under the name the route gives it;
  • query, holding what the method lets a request select — criteria, sort, limit, omit, search — and the parameters it declares;
  • the properties of the operation’s input, restricted by io:input.
type: object
properties:
  id: { type: string, pattern: ^[a-fA-F0-9]{32}$ }
  query:
    type: object
    properties:
      criteria: { type: string }
      sort: { type: string }
      limit: { type: integer, minimum: 1, maximum: 100, default: 10 }
      omit: { type: integer, minimum: 0, maximum: 1000, default: 0 }
required: [id]
additionalProperties: false

A property that a map directive fills is not in the schema: a tool call carries no headers of its own.

The schema is the operation’s contract, so the effort put into input schemas pays twice. Constraints and descriptions written there are what the model is held to.

A route with fixed criteria offers the model less to get wrong. /pending with criteria: status==pending is a better tool than / with instructions on how to filter.

§What a tool answers

The result carries the reply twice: as structuredContent, the reply itself, and as one text block of content holding its JSON. MCP allows structured content to be an object only, so a list or a single value is the result of one: { "result": [...] }. A reply of nothing is an empty content.

The output is restricted by io:output, as the route’s is.

A tool’s outputSchema is the operation’s declared output, restricted the same way. An operation that declares no output gets none; declare the operation’s output if clients should have the schema. An output that is not an object, such as the array a list declares, is stated as the result of one.

An operation that refuses — returns one of its declared errors — answers a result with isError: true carrying the error. This is not a protocol failure: a model reads it and may correct itself. Error codes that say what to do differently (NOT_PENDING, INSUFFICIENT_FUNDS) are therefore worth more than generic ones.

§Hints

Clients treat tools differently depending on what they may do — many let a model call read-only tools without asking the user. The hints are derived from the HTTP method:

MethodHint
GET, HEADreadOnlyHint
DELETEdestructiveHint, idempotentHint
PUTidempotentHint

Because a GET may not write, readOnlyHint is true of what the tool can do, not just of what it claims.

§Instructions

instructions is text a model is given before it calls anything. Use it for what no single tool description can say: where to start, how tools follow one another, what the identifiers mean.

# context.toa.yaml
exposition:
  mcp:
    name: Store
    instructions: >-
      Orders belong to a customer. List orders with `store.orders.GET`,
      read one with `store.orders._id.GET`.
      An order can be approved only while its status is `pending`.

Naming tools by their exact names in the instructions helps a model find the way.

§Who may call

A tool call is the call its route makes, by the same identity, under the same directives.

  • tools/list lists the published tools this caller may reach. A tool the caller’s identity would be refused is not listed.
  • tools/call for a name that is not published is 404, whether or not a route would have taken it. What the application did not publish cannot be reached by guessing its name.
  • A call the identity is not authorized to make is 403.

§Credentials

By default the endpoint requires a credential: a request without one is 401, with a challenge that tells the client where to obtain a token. MCP clients obtain one through OAuth, with the user’s consent; the endpoint is advertised as a protected resource in the Context:

# context.toa.yaml
exposition:
  oauth:
    authorize: https://store.example/account/allow/
    resources: ['/.mcp']
  mcp:
    name: Store

Setting up the authorization server, consent and scopes is covered in The OAuth Server.

To let the endpoint answer without a credential — for tools over public data — say so:

exposition:
  mcp:
    name: Store
    anonymous: true

Each tool is still authorized as its route: anonymous: true on the server opens the door, and only tools whose routes admit an anonymous caller are listed and callable. A route that is public over HTTP is a public tool; nothing has to be said twice.

§Browser clients

A request from a browser carries an Origin. The endpoint answers only the origins the Context lists:

exposition:
  mcp:
    name: Store
    origins: [https://claude.ai]

A request with an Origin that is not listed is 403; with no origins declared, no browser origin is admitted. A request without an Origin — any client that is not a browser page — is not affected.

§A host of its own

Users give their client an address. https://mcp.store.example is easier to hand out than https://api.store.example/.mcp. hosts names such a host per authority. local is the name the generated Context gives its one authority, here with the host it has in production:

# context.toa.yaml
exposition:
  authorities:
    local: api.store.example
  mcp:
    name: Store
    hosts:
      local: mcp.store.example
  • POST / of that host is the endpoint. It answers what POST /.mcp of the authority’s own host answers, which keeps working.
  • Nothing else is served there: the application’s routes, /.rpc, /.mcp and /.discovery are 404, and GET / is 405.
  • The host belongs to the authority it is declared under: identities and credentials are the same, and map:authority yields the same identifier.
  • A key of hosts that names no declared authority fails the deployment.
  • The host is exposed by the gateway’s ingress, like the authority’s own.
  • A host of its own is a protected resource without being listed in oauth.resources.

Describe the API reports the host as mcp, and the discovery page offers it as the address to give a client.

§Icons

A client that shows an icon for the server is given the icons of the application’s web manifest:

exposition:
  mcp:
    name: Store
    manifest: https://store.example/manifest.json
  • The URL is https, or http on a loopback host.
  • Only icons are taken from it. The server’s name is name.
  • An icon is used if it is served from the manifest’s own origin, its purpose is absent or includes any, and its type, where stated, is an image.
  • The manifest is read when the gateway starts and every half hour after; a changed icon reaches clients within an hour. Redirects are not followed, and a manifest over 16 KiB is not read.
  • If the manifest cannot be read the server has no icons, or keeps the last ones it read. The endpoint works either way.

§The protocol, briefly

Most developers never look below this line; an MCP client library does. For those who do:

  • One endpoint, POST only, one JSON-RPC message per request. GET and DELETE are 405.
  • No sessions and no streams: Mcp-Session-Id and Last-Event-ID are ignored, and neither listChanged nor subscriptions are offered.
  • Two protocol revisions are answered: 2026-07-28, where every request carries its version, and 2025-11-25, which opens with initialize. The revision is read from the MCP-Protocol-Version header, or from _meta of the message.
MethodAnswers
server/discoverWhat is served and by whom.
initializeThe same, for a client of 2025-11-25.
notifications/initialized202.
tools/listThe tools this caller may reach, in a stable order.
tools/callThe call.
ping{}.

server/discover and tools/list may be held by a client for half an hour, so a newly published tool can take that long to appear in a connected client.

Protocol errors:

CodeMeaning
-32700The body is not readable.
-32600The body is not one JSON-RPC message.
-32601No such method, or no such tool. Status 404.
-32602The arguments do not fit the tool’s schema.
-32603An exception.
-32020A header and the body disagree.
-32022A protocol revision that is not served.

The reasoning behind tools is in Procedures and Tools.

§Reference

exposition.mcp in the Context:

KeyMeaningDefault
nameWhat the server calls itself. Required.—
instructionsWhat a model is told before it calls anything.none
anonymousWhether the endpoint answers without a credential.false
originsBrowser origins the endpoint answers.none
hostsA host of its own per authority.none
manifestURL of the web manifest whose icons the server shows.none

In a manifest:

DirectiveValueMeaning
mcp:toolbooleanPublish, or withdraw, the methods at and under this node.
Search
Search the documentation