Overview
Sales engineers wanted to ask Claude questions like 'what's the state of the Acme renewal?' without copy-pasting CRM screenshots into chat. The obvious anti-pattern is a monster 'query_crm' tool that accepts arbitrary filters — it demos great and fails in practice because the model can't discover what's actually queryable. MCP's typed tool contract is the right shape for this: small, well-described tools with Zod-validated inputs.
The server exposes six read tools and one carefully-gated write tool (log_activity). Read tools return compact, LLM-shaped summaries rather than raw API payloads — a HubSpot deal object is ~4KB of JSON, of which the model needs maybe 200 bytes. The write tool requires an explicit confirmation token generated by a prior read, which turned out to be a simple and effective guard against the model logging activities to the wrong contact.
Architecture
- Node.js MCP server over stdio for local clients, with a streamable HTTP transport behind OAuth for the hosted variant.
- Six read tools (search_contacts, get_deal, get_pipeline_summary, get_activity_timeline, search_companies, get_contact_context) plus one write tool (log_activity).
- Every tool input is a Zod schema; descriptions are written for the model, with concrete examples of good and bad arguments.
- Responses are transformed from raw HubSpot payloads into ~10-line text summaries with stable entity IDs the model can pass to follow-up calls.
- The write path is two-phase: get_contact_context returns a short-lived confirm_token; log_activity rejects any call without a matching token.
- Per-session rate limiting and an audit log of every tool invocation, keyed by the OAuth principal, not the client name.
Code Snippets
server.registerTool(
"get_deal",
{
description:
"Fetch one deal by ID. Returns stage, amount, close date, " +
"and the last 3 activities. Use search_contacts first if " +
"you only have a company or person name.",
inputSchema: {
dealId: z.string().regex(/^\d+$/, "numeric HubSpot deal ID"),
},
},
async ({ dealId }) => {
const deal = await hubspot.deals.get(dealId);
const activities = await hubspot.activities.forDeal(dealId, { limit: 3 });
return {
content: [
{
type: "text",
text: summarizeDeal(deal, activities), // ~10 lines, not raw JSON
},
],
};
}
);const confirmTokens = new Map<string, { contactId: string; exp: number }>();
async function logActivity(args: {
contactId: string;
note: string;
confirmToken: string;
}) {
const token = confirmTokens.get(args.confirmToken);
if (!token || token.exp < Date.now()) {
return errorResult(
"Invalid or expired confirm token. Call get_contact_context " +
"first to verify the contact, then retry with its token."
);
}
if (token.contactId !== args.contactId) {
return errorResult(
"Token was issued for contact " + token.contactId +
", not " + args.contactId + ". Re-verify the contact."
);
}
await hubspot.notes.create(args.contactId, args.note);
confirmTokens.delete(args.confirmToken);
return textResult("Activity logged on contact " + args.contactId);
}Lessons Learned
- Tool descriptions are prompts. Rewriting descriptions with concrete argument examples improved correct tool selection more than any code change.
- Return summaries, not payloads. Raw API JSON blew through context budgets and made the model quote irrelevant fields; shaped 10-line summaries fixed both.
- Fewer, sharper tools beat one flexible tool. The 'universal query' design failed because the model couldn't discover valid filter combinations.
- Error messages must teach. Returning 'call get_contact_context first, then retry' let the model self-correct; a bare 403 produced retry loops.
- Gate writes with something the model must earn from a read. The confirm-token handshake eliminated an entire class of wrong-entity writes at near-zero cost.