Perform exposes a studio’s trainees, plans, programs, forms and tasks as Model Context Protocol tools. A coach connects an AI client with their studio API key and can ask questions or make small changes in plain language. There are two servers and one shared tool set. The WhatsApp AI assistant uses the same tool definitions. See WhatsApp webhooks and the agent.

One rule: tools call the automation lane

No tool touches the database. Each tool is a description of one HTTP call to /v1/automation:
createPerformClient({ apiKey, baseUrl }) in packages/mcp-tools/src/client.ts executes it with Authorization: Bearer and unwraps the automation envelope. The hosted endpoint does this as a loopback request to http://127.0.0.1:<PORT>/v1/automation on the same process. That extra hop costs microseconds and buys three things:
  • The MCP surface and the public API can never drift apart.
  • Every MCP call gets the lane’s validation, plan limits and audit logging unchanged.
  • Errors are the lane’s readable sentences, for example “No trainee with id X exists in this studio. Use Search trainees to get a valid id.” That is what a model needs to recover on its own, so failures are passed through as text and not reshaped into codes.
One key is one studio, so every tool is already scoped. There is no way to reach another studio’s data.

Hosted endpoint

Production URL: https://perform-api.otherwise.co.il/mcp. A coach pastes the URL and their key into an MCP client. Nothing to install.

Authentication

The handler checks that the header is a bearer token starting with pf_live_. If not, it answers 401 with a JSON-RPC error and a WWW-Authenticate: Bearer realm="Perform", error="invalid_token" header, which MCP clients read to work out how to authenticate. The message tells the user where to create a key. The key itself is not verified at this point. It is verified by the automation lane on the first tool call, so a well-formed but wrong key connects, lists tools, and then fails each call with “invalid partner api key”. There is no OAuth flow. Authentication is the static studio API key. See Partner API and API keys.

Stateless by design

Every request builds its own McpServer and its own StreamableHTTPServerTransport. Omitting sessionIdGenerator is what puts the transport in stateless mode. Consequences:
  • No session affinity. The endpoint scales with the API.
  • GET /mcp answers 405 with “This server is stateless; use POST.” There is no stream to resume and no session to delete.
  • The server and transport are closed when the response closes.

Rate limit and errors

/mcp is mounted in app.ts with the same global limiter as /v1: RATE_LIMIT_MAX requests per RATE_LIMIT_WINDOW_MS. Each tool call also makes one loopback request to /v1/automation, which passes through the limiter again. An unexpected failure answers 500 with JSON-RPC error code -32603. A tool failure is not a transport error: it returns isError: true with the message as text content.

Annotations

Each tool is registered with readOnlyHint from its readOnly flag, destructiveHint: false and openWorldHint: true. No tool deletes data or messages a trainee, so none is destructive.

Local binary

apps/mcp-server/src/index.ts is the same tool set over stdio, for clients that launch a local process. Example client configuration:
It reads process.env directly on purpose. It runs on a coach’s own machine with two variables and must not pull in the server’s config schema, which demands a database and the full API environment. client.validate() calls GET /validate and returns the studio name and slug.

What is deliberately left out

The scope is reads plus “soft” writes. A model acts without a human watching each step, so anything that destroys data or reaches a real trainee’s phone is not a tool: Writes that can notify are forced silent in the tool’s call: A coach opting into notifications is a decision for the app, not for a model. send_form is the exception: it passes notify through, because the default (the hourly notifier announces the form once) is already the gentle path.

Tools

21 tools: 13 read-only and 8 writes. Paths are relative to /v1/automation.

Read-only

limit is 1 to 200 with a default of 50. offset starts at 0.

Writes

create_task is the right way for a model to tell a coach to do something. It does not reach the trainee. Tool descriptions are written for the model. They say when to use the tool, where ids come from (“From list_plans”), and what null means on a plan’s price and duration.
The assign_plan tool describes startedOn as defaulting to today. The API does something else when it is omitted: the new plan starts when the trainee’s last live plan ends. The API behaviour is what happens. The tool description is out of date.

Which tools the WhatsApp assistant gets

AGENT_TOOLS in apps/core-api/src/modules/agent/agent.llm.ts is a subset:
Every read, plus create_task. The other writes change a trainee’s world from a chat message and wait for a confirmation step before they are worth the risk.

Adding a tool

1

Make sure the automation lane can do it

A tool is only a call to /v1/automation. If the endpoint does not exist, add it there first, with its Zod schema and readable error messages.
2

Add the definition

Append to tools in packages/mcp-tools/src/tools.ts. Describe every input with .describe(). Set readOnly honestly, because the WhatsApp assistant’s tool list is derived from it.
3

Decide what the model may not control

If the endpoint can notify a trainee, force the flag off in call. Do not expose it as an input.
4

Rebuild the package

@perform/mcp-tools is consumed through its built output by the hosted endpoint, the local binary and the assistant. A new tool appears in all three after a rebuild and deploy.
There are end-to-end scripts for both transports under packages/database/scripts: mcp-http-e2e.mts and mcp-server-e2e.mts.