--- title: "Shipping an MCP server for autonomous underwriting agents" description: "Exposing 210 endpoints as agent tools does not work. What we learned designing a tool surface an agent can actually reason about." date: 2026-01-02 category: "Agents" author: "The Moneyline team" reading_time: "6 min read" tags: ["MCP", "Agents", "API design"] canonical_url: https://moneyline.co/blog/mcp-server-for-underwriting-agents --- # Shipping an MCP server for autonomous underwriting agents > Exposing 210 endpoints as agent tools does not work. What we learned designing a tool surface an agent can actually reason about. Agents · Jan 2, 2026 · 6 min read · Source: https://moneyline.co/blog/mcp-server-for-underwriting-agents Our first MCP server was generated from the OpenAPI spec. Every endpoint became a tool, so the server exposed 210 of them. It was complete, it was correct, and agents were noticeably worse at using it than at using the raw HTTP API through a generic fetch tool. ## Why the generated server failed A REST API is organized around resources, because that is what a human integrator navigates with documentation open. An agent has no documentation open. It has a list of tool names and descriptions, and it picks one. With 210 tools, three things went wrong at once. Selection accuracy collapsed, because a dozen names were plausible for any given intent. The tool list consumed a large share of the context window before any work started. And correct sequences were long: parse, then enrich, then score, then fetch, with an id threaded through each step and four chances to drop it. ## Designing for intent, not for resources We rewrote the surface around what someone actually asks an underwriting agent to do. Fourteen tools, each mapping to a complete intent rather than an HTTP call. *apps/mcp/src/tools/analyze-submission.ts* ```typescript /** * One tool per intent, not per endpoint. This runs parse, enrich, and * score internally: the agent need not know that sequence, and * every step it does not orchestrate is a step it cannot get wrong. */ export const analyzeSubmission = defineTool({ name: 'analyze_submission', description: 'Parse every document in a submission, enrich the ' + 'transactions, and return underwriting signals: income, ' + 'cashflow, NSF counts, existing positions, and fraud flags. ' + 'Use this to answer questions about an applicant. Do not ' + 'call the parse or enrich tools separately first.', input: z.object({ submissionId: z .string() .describe('From list_submissions or create_submission.'), include: z .array(z.enum(['income', 'cashflow', 'positions', 'flags'])) .default(['income', 'cashflow', 'positions', 'flags']), }), async run({ submissionId, include }, ctx) { const documents = await ctx.api.parseAll(submissionId); const enriched = await ctx.api.enrich(documents); // Shaped for reading, not for storage. return summarize(enriched, include); }, }); ``` The description does real work here. "Do not call the parse or enrich tools separately first" removed most of the redundant tool calls we saw in traces, which is a reminder that tool descriptions are prompt engineering whether you treat them that way or not. ## Return summaries, not payloads The generated server returned API responses verbatim. A parsed 40-page statement is a large JSON document, and dropping it into the context is both expensive and counterproductive: the agent then has to do arithmetic over hundreds of rows, which is exactly the task it is worst at. Every tool now returns a computed summary with the figures already derived, plus a reference the agent can use to fetch detail if it genuinely needs it. - **Compute in the tool.** Average daily balance, NSF count, and deposit variance are arithmetic. Do them in TypeScript and hand over the number. - **Reference, do not inline.** Return `document_id` and let the agent ask for rows if it needs them. Most of the time it does not. - **Keep units explicit.** `amount_minor` with a currency code, never a bare float. Agents infer units confidently and wrongly. ## Guardrails An underwriting agent operates on real applications, so some actions must not be one tool call away from a plausible-sounding instruction in a document. Two rules, enforced in the server rather than in the prompt. 1. Tools are read-only by default. The four that write are gated behind an explicit scope on the API key, so a token issued for analysis cannot approve anything. 2. Decision tools return a recommendation and a reason, never a committed decision. Recording the outcome is a separate call that a human or an owning service makes. > **Documents are untrusted input** > > A submitted PDF can contain text addressed to the agent reading it. Parsed content is returned inside a delimited block flagged as untrusted, and the server strips nothing: pretending the text is not there is worse than labelling it. ## What the numbers did Measured over the same 200-task evaluation set, moving from 210 generated tools to 14 designed ones took end-to-end task completion from 61% to 94%, cut mean tool calls per task from 11.3 to 3.8, and cut mean tokens per task by roughly two thirds. None of that came from a better model. It came from asking the agent to make fewer decisions. ## Connecting to it The server ships in the box and speaks stdio, so any MCP client can run it locally against your own key. *claude_desktop_config.json* ```json { "mcpServers": { "moneyline": { "command": "npx", "args": ["-y", "@moneyline/mcp"], "env": { "MONEYLINE_API_KEY": "sk_live_..." } } } } ``` Point it at a self-hosted instance with `MONEYLINE_BASE_URL` if you would rather the documents never leave your infrastructure. The tool surface is identical either way.