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.
Hosted endpoint
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 withpf_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 ownMcpServer 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 /mcpanswers 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 withreadOnlyHint 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:
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:
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.packages/database/scripts: mcp-http-e2e.mts and mcp-server-e2e.mts.