Serve tools over MCP

Put a vendor's read operations, your stores and your runs behind one MCP address your engineers and applications attach to
Serve my workspace's tools over MCP
Put my Speechify OpenAPI tool behind a hosted API with the MCP face switched on: create the API with a keyed audience in the tool’s project, add a tool route for each read operation with a clear name and description, switch on mcp\_enabled, issue a consumer key, then attach the API’s /mcp address to this coding agent and list its tools. Follow [https://docs.speechify.ai/build/guides/hosted-apis/serve-tools-over-mcp.md](https://docs.speechify.ai/build/guides/hosted-apis/serve-tools-over-mcp.md).

Hosted APIs are in beta and enabled per workspace. Without the grant every /v1/apis endpoint here answers 402 hosted_apis_not_in_plan.

A hosted API can serve its routes as an MCP server at POST https://<slug>.apis.speechify.ai/mcp. One address gives an MCP client the connectors, stores and runs your workspace assembled, under the same audience, keys, limits and daily caps as the routes themselves. Every tool call runs through the same path an ordinary HTTP request to the route takes, so nothing the route enforces can be skipped over MCP.

What you end up with

  • A hosted API, acme-tools.apis.speechify.ai, that only callers holding its key can reach.
  • A tool route for each vendor read operation, and any store or run routes you want beside them.
  • An MCP client, such as Claude Code or Cursor, listing those routes as tools.

This page assumes an openapi or mcp tool already exists; Connect a REST API builds an openapi one, and Add tools an mcp one.

1. Create the API

POST
/v1/apis
curl -X POST https://api.speechify.ai/v1/apis \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"slug": "acme-news",
"name": "Acme News"
}'
{
"slug": "acme-tools",
"name": "Acme security tools",
"description": "Read Acme's open security issues from the vendor. Use list_open_issues to find issues by severity and get_issue for one issue's detail.",
"auth_mode": "consumer_key",
"mcp_enabled": true,
"project_id": "proj_01arz3ndektsv4rrffq69g5fav"
}
  • Create it in the tool’s project. A tool route refuses a tool from another project with 409 cross_project_reference.
  • Pick an audience that names its callers. consumer_key (a ck_ key you mint), workspace or owner (a Speechify API key of your workspace) and user_token (a JWT your backend signs) all work. public is refused with mcp_enabled, and it never serves a tool route. Pick workspace or owner for your own people: each attaches with their own Speechify API key.
  • Write name and description for a model. On the MCP face, name is the server’s title and description is the instructions the client’s model reads before it chooses a tool.

mcp_enabled can also be switched on later with PATCH /v1/apis/{api_id}. Allow up to 15 seconds for the switch to reach every server.

2. Add a tool route

POST
/v1/apis/:api_id/routes
curl -X POST https://api.speechify.ai/v1/apis/api_01kc4q3r9s6t8v0w2x4y6z8a0b/routes \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"method": "POST",
"path": "/issues/open",
"resolver": {
"type": "tool",
"tool_id": "tool_01kc4m8r2t6v9x3z5b7d1f3h5k",
"operation": "list_open_issues"
},
"name": "list_open_issues",
"description": "List the vendor'\''s open security issues, newest first. Pass severity to narrow them."
}'
{
"method": "POST",
"path": "/issues/open",
"name": "list_open_issues",
"description": "List the vendor's open security issues, newest first. Pass severity to narrow them.",
"resolver": {
"type": "tool",
"tool_id": "tool_01kc4m8r2t6v9x3z5b7d1f3h5k",
"operation": "list_open_issues"
}
}
  • operation is the id of one of the tool’s operations.
  • A tool route is always a POST: its JSON body is the operation’s arguments, checked against the operation’s own argument schema.
  • The operation must be classified read, and the tool’s approval must be null or auto, because a route has nobody to approve a call. A route write that breaks either rule is refused with 400 validation_failed, and the message names the fix. Every call checks both again: reclassifying the operation, or giving the tool any other approval, turns the route into a 403 route_tool_not_readable until you change it back.
  • Deleting the tool, moving it to another project, or removing the operation turns the route into a 409 route_tool_unavailable, which no retry clears: point the route at a tool that has the operation.
  • The paths /mcp, /openapi.json, /_runs and /.well-known/oauth-protected-resource, and everything under them, are reserved for the platform, so a route cannot use them.
  • The response is the vendor’s answer after the operation’s response mapping, or {"text": ...} when the vendor answered text.
  • Every call counts against the API’s daily_read_cap and the tool’s max_requests_per_minute.

On the MCP face the tool’s name comes from the route’s name: every run of characters other than letters, digits, _ and - becomes _, leading and trailing _ are dropped, and the result is cut to 64 characters. A route with no name is listed under a name built from its method and path, such as post_issues_open, and a name two routes would share takes _2, _3 in route order. The face’s tools/list is the authority, so read names from it rather than deriving them. The route’s description is what the model reads to choose the tool, falling back to the operation’s summary when empty. A vague route name costs you more here than anywhere else, because a model picks from names and descriptions alone.

Mount a whole connector at once

Instead of one route per operation, mount the connector: one call writes a tool route for each operation it offers, named <name_prefix>__<operation> so two connectors’ tools never collide on the MCP face.

POST
/v1/apis/:api_id/routes/mount
curl -X POST https://api.speechify.ai/v1/apis/api_01kc4q3r9s6t8v0w2x4y6z8a0b/routes/mount \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"tool_id": "tool_01kc4n9s3v7w0y4a6c8e2g4j6m",
"name_prefix": "linear",
"dry_run": true
}'
{ "tool_id": "tool_01kc4n9s3v7w0y4a6c8e2g4j6m", "name_prefix": "linear", "dry_run": true }
  • Preview first. With dry_run: true nothing is written; each entry in operations says what a mount would do (create, update, unchanged, skip or stale), and every skip and stale carries the reason and what fixes it.
  • Apply by sending the same body without dry_run, with the preview’s plan_digest. Every create and update lands in one transaction, and each entry carries the written route. The apply writes only the plan the preview showed: if the connector or the API’s routes changed since, it writes nothing and answers 409 mount_plan_changed with the current plan in error.details.plan, to review and apply with its own plan_digest.
  • Pick with operations. Omit it to create a route for every operation a route can serve.
  • Refresh by mounting again. Routes you already have are compared with what the connector offers now: an MCP tool whose input schema changed is an update, and a route whose operation is gone or can no longer be served is stale. A stale route is never deleted, since a client may still call it; delete it yourself when you are ready. A route you renamed or moved keeps its name and path.
  • An API holds at most 200 routes.

Let people write through the gateway

A route can serve a tool that is not a read, such as filing an issue, when three things hold:

  • The route has "allow_write": true (mount with "include_writes": true to create write routes for every write tool at once).
  • The API’s auth_mode names a person: owner, workspace or user_token. A consumer_key or public API never serves a write, because a key names no person and the write would act for nobody.
  • The tool’s effective approval is auto. A tool that needs approval stays refused, since nobody on the route can approve it.

A write acts for the person calling: your connector receives their id in Speechify-User-Identity, and the request log names them. Each write counts against the API’s daily_write_cap, and a client that retries with the same Idempotency-Key gets the first answer back instead of a second issue. A service account key cannot write through a route. While a route serves writes, the API cannot be switched to consumer_key or public.

Put an MCP server’s tool behind a route

A tool route can front one tool of an mcp tool definition, so the MCP servers your team already uses sit beside your REST vendors on one address. operation is the server’s own tool name, as POST /v1/agents/tool-definitions/test-mcp-connection lists it.

{
"method": "POST",
"path": "/linear/issues",
"name": "linear_list_issues",
"description": "List a Linear team's open issues.",
"resolver": {
"type": "tool",
"tool_id": "tool_01kc4n9s3v7w0y4a6c8e2g4j6m",
"operation": "list_issues"
}
}
  • The route write lists the server’s tools, so the server has to be reachable with the tool’s credential at that moment, and it has to speak streamable HTTP; a server that cannot be listed is refused with the server’s own reason.
  • The tool must be classified read in the definition’s action_classes (or declared read by the server under the definition’s action_class), and its effective approval must be auto.
  • The response carries resolver.input_schema: the tool’s input schema as the server listed it, pinned on the route. The MCP face lists the pin and every call is checked against it, so a change the server ships reaches no client until you write the route again.
  • Calls reuse one warm session to the server, carry the tool’s vault credential, and send your consumer’s Idempotency-Key as Speechify-Idempotency-Key.
  • The response is the tool’s structured content, its text when that text is JSON, {"text": ...} for plain text, or {"content": [...]} with every block as the server sent it when one is not text. The definition’s response_mappings entry for the tool applies first.
  • When the tool reports an error, the route answers 502 route_upstream_error with the tool’s own message in error.details.tool_error, so the model can correct its arguments.

Every other enabled route is a tool too: a store query, an aggregate, a write or a run, each taking the parameters the route binds from a request. A file route is not listed, since a tool result cannot carry a file’s bytes.

3. Issue a key

POST
/v1/apis/:api_id/keys
curl -X POST https://api.speechify.ai/v1/apis/api_01jqr8x9zg5k2m3n4p5q6r7s8t/keys \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"name": "string"
}'

The response carries the ck_ secret once; store it now. rate_per_minute bounds each key, and a client over it gets 429 on its next request. On a workspace or owner API skip this step: a Speechify API key of your workspace is the credential.

4. Attach an MCP client

The face speaks MCP over stateless streamable HTTP and takes POST only.

claude mcp add --transport http --scope user acme-tools https://acme-tools.apis.speechify.ai/mcp \
--header "Authorization: Bearer $ACME_TOOLS_KEY"

Every read of the API carries the address as mcp_url while the face is on, and a client that finds the API some other way can read it from the API’s OpenAPI document: GET https://acme-tools.apis.speechify.ai/openapi.json carries it as x-speechify-mcp.url.

Sign-in is not available yet

A hosted API’s /mcp takes a key or a token, never a sign-in, in every workspace: attach a workspace or owner API with a Speechify API key of its workspace, as above. A client that can only sign in and cannot send an Authorization header, such as a claude.ai or ChatGPT connector, cannot attach to a hosted API yet.

See how each connector is doing

GET /v1/apis/{api_id}/usage reports, per route and for today, tool_calls, upstream_errors, upstream_throttles and upstream_latency_ms_mean. When an engineer says the gateway is slow, this is where a slow or failing vendor shows up as that vendor.

What a call looks like to the client

  • A route that answers successfully is a tool result whose text is the route’s JSON, with the same JSON as structured content when it is an object.
  • A route that refuses answers a tool result with isError: true and the error envelope as its text, such as route_upstream_rate_limited. The client’s model reads the code and can wait or change its arguments.
  • A run or write tool takes an optional idempotency_key argument, sent as the route’s Idempotency-Key, so a client retry replays the first answer instead of acting twice.
  • A tool route is annotated read-only, and tool and run routes are annotated as reaching beyond the API, so a careful client can decide what to confirm with its user.

Errors

CodeStatusWhereWhat to do
hosted_mcp_not_enabled404/mcpSet mcp_enabled: true with PATCH /v1/apis/{api_id}; the face is never served on a public API
validation_failed on mcp_enabled400/v1/apisThe API is, or is being made, public; pick an audience that names its callers
validation_failed400Route create or updateThe tool is not an openapi or mcp tool you can reach, has no such operation, the operation is not read, the tool’s approval is not null or auto, or its MCP server could not be listed; the message names which
validation_failed on path400Route create or updateThe path is reserved (/mcp, /openapi.json, /_runs, /.well-known/oauth-protected-resource); pick another
unauthorized401/mcpNo credential, or one this API refuses; send the API’s key or token, which on a workspace or owner API is a Speechify API key of its workspace
forbidden403/mcpThe key belongs to another workspace or to someone who is no longer a member of the API’s workspace, or the API is owner and the key is not its owner’s
cross_project_reference409Route createCreate the API in the tool’s project
route_tool_not_readable403Tool callThe operation is no longer classified read, or the tool’s approval is no longer null or auto; set it back with PATCH /v1/agents/tool-definitions/{tool_definition_id}
route_upstream_rate_limited429Tool callThe tool’s max_requests_per_minute or the vendor throttled the call; wait for Retry-After
route_upstream_error502Tool callThe vendor answered an error (its status is in error.details.upstream_status), an MCP tool reported one (its message is in error.details.tool_error), the server could not be reached, or the tool’s credential no longer resolves
route_tool_unavailable409Tool callThe tool was deleted, moved to another project, or no longer has the operation (an MCP server stopped listing it); retrying will not help, so point the route at a tool that has it
route_read_limit_reached429Tool callThe API has served its daily_read_cap today; raise it
rate_limited429Any requestThe key’s rate_per_minute is spent
hosted_api_busy429Any requestThe API is answering as many requests at once as it may, often tool calls waiting on a slow vendor; wait for Retry-After. A call and the /mcp request that carries it count once

Where the rest lives