Skip to main content
Moneyline
All articles
Agents6 min read

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.

The Moneyline team · Engineering
View as .md
Hands holding a bank card over an open laptop

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.tstypescript
/**
 * 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. 01Tools 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. 02Decision 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.

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.jsonjson
{
  "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.

MCPAgentsAPI design

Keep reading

Parse your first document

Everything in this article runs on the open-source core. Clone it, or start on the hosted API.