Developer API
Build an MCP server for Cove
A technical guide to building a read-only MCP server that Cove, the AI agent, can call to answer account-specific questions - the protocol, identity scoping, signature verification, and how to design tools the agent reads well.
This is the developer companion to Connected Data. It covers how to build an MCP server that Cove can call to look up a verified customer’s data. If you only need a single lookup, a REST endpoint is simpler — this guide is for exposing several read tools at once.
Cove speaks Streamable-HTTP JSON-RPC 2.0 (MCP). You implement one HTTPS endpoint (e.g. POST /mcp) that handles a few methods.
The handshake
When Cove connects, it calls these methods in order over POST to your URL. Each request is a JSON-RPC 2.0 envelope.
-
initialize— Cove sends its protocol version and client info. Respond with yourprotocolVersion,capabilities: { tools: {} }, andserverInfo. You may set anmcp-session-idresponse header; Cove echoes it back on later calls. -
notifications/initialized— a best-effort notification (noid, no response expected). -
tools/list— return your tool catalog (see below). -
tools/call— Cove invokes a tool with{ name, arguments }. Return the result.
Cove sends these request headers, which your server should accept:
content-type: application/json
accept: application/json, text/event-stream
mcp-protocol-version: 2025-06-18
mcp-session-id: <echoed from initialize, if you set one>
You may reply with either a JSON body or an SSE stream (text/event-stream with data: lines) — Cove parses both.
Declaring tools
tools/list returns an array of tool descriptors. Cove only exposes a tool to the agent if it is marked read-only — set annotations.readOnlyHint: true on every tool you want used. Anything without it is ignored.
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"tools": [
{
"name": "get_booking",
"description": "Look up a single booking in this merchant's account by order number.",
"inputSchema": {
"type": "object",
"properties": {
"order_number": { "type": "string", "description": "the customer's order number" }
},
"required": ["order_number"]
},
"annotations": { "readOnlyHint": true }
}
]
}
}
The name and description are how the agent decides which tool fits a question, so write them for that purpose. Prefer several focused, well-named tools (get_order, get_booking, get_plan) over one catch-all — the agent maps a question to a tool by name and description.
Read-only is enforced on both sides: your server should expose only reads, and Cove will refuse to call anything not flagged readOnlyHint: true. Never expose a tool that writes, refunds, cancels, or edits.
Identity scoping (the security crux)
Cove runs your tools only for a customer whose identity it has cryptographically verified, and it injects that customer’s key into the call’s arguments itself — after the model’s arguments — so the model can never widen the lookup to a different customer.
Two rules for your server:
-
Do not declare the identity field in your tool’s
inputSchema. Cove adds it. For example, if you key tenants by Shopify domain, Cove injectsshop: "<verified-domain>"intoarguments; your schema should only describe the lookup keys the model supplies (an order number, a date, a service name). - Require it and scope every query by it. If the injected key is missing, reject the call. Never trust a tenant/customer identifier that came from anywhere else.
// inside tools/call
const shop = args.shop; // injected by Cove, not by the model
if (!shop) return rpcError(-32602, "shop is required");
const account = await Account.findByDomain(shop);
const booking = await account.bookings.findByOrderNumber(args.order_number);
Verifying the signature
Every request Cove sends is signed so you can confirm it came from Convot and the body wasn’t tampered with. Verify it before doing anything else.
X-Convot-Timestamp: <unix seconds>-
X-Convot-Signature: v1=<hex>where<hex> = HMAC-SHA256(secret, "<timestamp>.<raw request body>")
Convot generates the signing secret and shows it in the Connected Data editor — store it as an environment variable on your server. Reject the request if the timestamp is more than 300 seconds old (replay protection) and compare signatures in constant time.
const crypto = require("crypto");
function verifyConvot(req) {
const ts = req.get("X-Convot-Timestamp") || "";
const sig = (req.get("X-Convot-Signature") || "").replace(/^v1=/, "");
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; // replay window
const expected = crypto
.createHmac("sha256", process.env.MCP_SIGNING_SECRET)
.update(ts + "." + req.rawBody) // req.rawBody = the exact bytes received
.digest("hex");
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig));
}
Sign over the raw request body bytes, not a re-serialized object — re-encoding changes whitespace and key order and the signature won’t match. The Connected Data editor also gives you a copy-paste verification snippet for Ruby, Node, and Python.
Returning a result
A tools/call result is MCP content. Return your data as a text block (JSON-encoded is fine):
{
"jsonrpc": "2.0",
"id": 3,
"result": {
"content": [
{ "type": "text", "text": "{\"status\":\"Confirmed\",\"start_at\":\"2026-03-21T14:30:00Z\",\"zoom_link\":null}" }
]
}
}
Cove joins the text blocks (or falls back to structuredContent) and hands them to the agent as a verified source. Keep results compact — long results are trimmed, so return the fields that answer the question, not the whole record.
Designing tools the agent reads well
The agent reads your tool output the way a sharp new teammate would. Clean, decision-shaped data produces correct answers; ambiguous raw state produces confident wrong ones. These are the patterns that matter most in practice:
Return a conclusion, not a database dump. Lead with a verdict the agent can act on. For a setup check, this:
{
"calendar_status": "blocked",
"blocking_issues": ["The product is added but not activated, so its calendar won't show."],
"recommended_action": "Activate the product under Settings > Products.",
"app_embed_enabled": true
}
…produces a correct one-shot answer. A raw dump like { "items": [], "records": [...] } invites the agent to read meaning into an empty array. The agent takes your fields at face value, so an ambiguous value can lead to a confident wrong answer — keep the shape unambiguous and lead with the verdict.
Surface relationships, not just values. If availability is inherited from a team member, or a global mode overrides a per-item setting, say so explicitly — availability_mode: "business_hours", availability_source: "team_member". Otherwise the agent answers from the value it can see and misses the relationship that actually governs it.
Pre-compute anomalies. If a config is self-contradictory (a min/max that blocks all bookings, an override that caps slots below the default), flag it in an anomalies array. Turning “why can’t anyone book?” into a one-shot answer is exactly what this unlocks.
Name fields the way your customer thinks, and document anything whose empty or zero state could read as a fault.
A minimal endpoint
app.post("/mcp", express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }), async (req, res) => {
if (!verifyConvot(req)) return res.status(401).json(rpcError(-32001, "Unauthorized", req.body.id));
const { method, params, id } = req.body;
switch (method) {
case "initialize":
return res.json({ jsonrpc: "2.0", id, result: {
protocolVersion: "2025-06-18",
capabilities: { tools: {} },
serverInfo: { name: "my-store", version: "1.0" },
}});
case "notifications/initialized":
return res.status(204).end(); // notification: no body
case "tools/list":
return res.json({ jsonrpc: "2.0", id, result: { tools: TOOLS } });
case "tools/call": {
const text = await runTool(params.name, params.arguments); // scope by params.arguments.shop
return res.json({ jsonrpc: "2.0", id, result: { content: [{ type: "text", text }] } });
}
default:
return res.json(rpcError(-32601, "Method not found", id));
}
});
Before you connect it
- Public HTTPS URL (internal/localhost addresses are blocked).
-
initialize,tools/list,tools/callimplemented;notifications/initializedaccepted. - Every exposed tool has
annotations.readOnlyHint: trueand performs only reads. - The identity key (e.g.
shop) is required and every query is scoped by it; it is not in any tool’sinputSchema. - HMAC signature verified over the raw body, with a 300-second window.
- Tool results are compact and decision-shaped.
Then add the server under AI Agent → Connected data, click Test connection, and Cove will list every read-only tool it discovered. See Connected Data for the dashboard steps.
Was this article helpful?
Thanks for your feedback!