Agents
MCP server
LeanTools speaks the Model Context Protocol over Streamable HTTP. One endpoint, /mcp, exposes the tools the workspace has switched on: the native web search and email tools, LeanData datasets and LeanConnect connector actions. Each tools/call is billed like the REST call it wraps and admitted by the same rules. LeanBrowser, LeanModel, LeanMachine, LeanVision and LeanMemory are not MCP tools; they take the same key over REST and WebSocket (see the reference).
Transport#
POST /mcpwith one JSON-RPC 2.0 message per request. Stateless: no SSE stream, no session id, no batching.- Authenticate with the workspace API key as a bearer:
Authorization: Bearer lt-…. - Protocol versions
2025-06-18,2025-03-26and2024-11-05are negotiated; an unknown version is answered with the latest. Accept: text/event-streamis tolerated but the answer is always a single JSON document. Notifications (noid) are acknowledged with202and no body.GETandDELETE /mcpanswer405. Bodies overLeanTools.Mcp.MaxBodyBytes(1 MiB) are413.
Initialize and ping#
curl -s https://YOUR-HOST/mcp -H "Authorization: Bearer lt-..." -H "Content-Type: application/json" \
-d '{ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": { "name": "my-agent", "version": "1.0" } } }'{ "jsonrpc": "2.0", "id": 1, "result": { "protocolVersion": "2025-06-18", "capabilities": { "tools": { "listChanged": false } },
"serverInfo": { "name": "LeanTools", "version": "1.0.0" }, "instructions": "LeanTools tools server. Every tool call is billed in credits ..." } }ping answers {}.
The catalogue: tools/list#
The list is computed per workspace and per key: it holds only the tools the workspace has enabled and the key is scoped to. Names use dots; the underscore form (data_query) is accepted on calls for clients that cannot send dots.
| Tool | Kind | Billing |
|---|---|---|
web_search.search | Native | One Web Search request per query |
email.send | Native | One Email email per recipient (to + cc + bcc) |
data.list, data.describe | Data | Free |
data.query | Data | One call plus row per 1,000 rows, at the dataset's rate |
data.documents.list, data.documents.get | Data | One call plus one row per summary or document |
data.subscribe, data.unsubscribe | Data | The first month at subscribe; nothing to cancel |
connector.<key>.<action> | Connector | One connector call at the connector's rate, only when the action completed |
{ "jsonrpc": "2.0", "id": 2, "result": { "tools": [
{ "name": "web_search.search", "title": "Web Search: search",
"description": "Searches the web through the platform's search engine and returns ranked results (title, url, description). Billed 0.005 credits per query.",
"inputSchema": { "type": "object", "properties": { "query": { "type": "string" }, "resultCount": { "type": "integer", "minimum": 1, "maximum": 50 }, "language": { "type": "string" } }, "required": ["query"], "additionalProperties": false },
"annotations": { "readOnlyHint": true, "destructiveHint": false, "idempotentHint": true, "openWorldHint": true }, "kind": "NativeSearch" },
{ "name": "data.query", "title": "Query a dataset", "description": "Runs a filtered, ordered, paged query ... Billed per call plus per 1,000 rows returned ...",
"inputSchema": { "type": "object", "properties": { "dataset": { "type": "string", "enum": ["leantools-usage.service-usage"] }, "filters": { "type": "array" }, "take": { "type": "integer" } }, "required": ["dataset"] },
"annotations": { "readOnlyHint": true, "destructiveHint": false, "idempotentHint": true, "openWorldHint": false }, "kind": "DataQuery" },
{ "name": "connector.twilio.sms.send", "title": "Twilio: Send SMS", "description": "... Billed 1 credits per completed call. Needs a Twilio credential.",
"inputSchema": { "type": "object", "properties": { "input": { "type": "object" }, "credentialId": { "type": "string" }, "credential": { "type": "object" } } },
"annotations": { "readOnlyHint": false, "destructiveHint": false, "idempotentHint": false, "openWorldHint": true }, "kind": "ConnectorAction", "itemKey": "Twilio", "actionKey": "sms.send" }
] } }Annotations follow the MCP specification so harnesses can ask before destructive calls. Connector actions a workspace has disabled in the console are absent from the list; a connector's default credential is used when a call names none.
Calling a tool: tools/call#
{ "jsonrpc": "2.0", "id": 3, "method": "tools/call",
"params": { "name": "data.query", "arguments": { "dataset": "leantools-usage.service-usage", "take": 50, "orderBy": "day", "descending": true } } }{ "jsonrpc": "2.0", "id": 3, "result": { "content": [ { "type": "text",
"text": "{\"dataset\":\"leantools-usage.service-usage\",\"columns\":[...],\"rows\":[[...]],\"nextPageToken\":\"NTA=\",\"totalCount\":null,\"truncated\":false,\"usage\":{\"credits\":0.105,\"balance\":1868.05,\"rows\":50}}" } ], "isError": false } }The text is the JSON the REST route would answer plus its usage block. Results over LeanTools.Mcp.MaxResultChars (200,000 characters) drop trailing rows and say so (truncated: true, rowsShown, rowsFetched) while keeping nextPageToken; the rows fetched were billed, so ask for smaller pages.
Refusals and failures#
A refused or failed call is a result with isError: true whose text carries the same code and status the REST API would answer, so the agent can read the reason and react:
{ "jsonrpc": "2.0", "id": 4, "result": { "content": [ { "type": "text",
"text": "{\"error\":{\"code\":\"insufficient_credits\",\"message\":\"The account has no credits left. Top up or renew the subscription to continue.\",\"status\":402}}" } ], "isError": true } }A connector write held for approval is such a result too: approval_pending, status 202, and error.details with approvalId, expiresUtc and pollUrl the agent can poll (see approvals).
Protocol errors#
| Situation | HTTP | JSON-RPC error |
|---|---|---|
| Missing or invalid key | 401 (403 for a suspended workspace), with WWW-Authenticate: Bearer realm="LeanTools", error="invalid_token" | -32001, data.code = authentication_error |
| Unknown tool | 200 | -32602 |
| Unknown method | 200 | -32601 |
| Malformed JSON | 200 | -32700 |
Batch (array) or message without method | 200 | -32600 |
Native tools#
web_search.search#
queryrequiredWhat to search for.resultCountinteger 1–50Results per page; the engine default when omitted.languagestringTwo-letter language code, e.g.en.
Returns { query, totalResultCount, items[{ position, title, url, description }] }. The search engine is chosen by the host; the caller never picks it.
email.send#
torequiredRecipient address.subjectrequiredSubject line.bodyrequiredPlain text, or HTML whenisBodyHtmlis true.from,fromName,toName,cc,bcc,isBodyHtmlOptional. Omitfromto send from the platform address.
Returns { isSuccess, provider }. Because sending mail is a write, harnesses that honour annotations ask before calling it (readOnlyHint: false).
Bring your own server#
Any remote MCP server can be registered as a connector (the Generic MCP connector type): give it a URL and a credential, and its tools appear in the catalogue as connector.<key>.<tool>, metered like every other connector call and subject to the same guardrails. Stdio transports are not accepted by a hosted gateway (403 transport_not_allowed).
Limits#
LeanTools.Mcp.MaxBodyBytes- Request body limit, 1 MiB by default (
413). LeanTools.Mcp.MaxResultChars- Result text limit, 200,000 characters by default; longer results are truncated with a page token.
- Per-connector rate limit and daily cap
- Set in the console;
429 rate_limited/429 daily_cap_reached. - In-flight reserve
- See Credits and billing.