<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:media="http://search.yahoo.com/mrss/"><channel><title><![CDATA[Zuper Labs]]></title><description><![CDATA[Experiments with AI & Technology]]></description><link>https://labs.zuper.co/</link><image><url>https://labs.zuper.co/favicon.png</url><title>Zuper Labs</title><link>https://labs.zuper.co/</link></image><generator>Ghost 5.88</generator><lastBuildDate>Fri, 17 Jul 2026 05:32:02 GMT</lastBuildDate><atom:link href="https://labs.zuper.co/blog/rss/" rel="self" type="application/rss+xml"/><ttl>60</ttl><item><title><![CDATA[How we built the Zuper MCP: intent-driven testing, module-wise coverage, and closing 100+ schema gaps]]></title><description><![CDATA[<h1 id></h1><h2 id="the-problem-we-started-with">The problem we started with</h2><p>Zuper is a field service management platform with around 30 core business domains &#x2014; jobs, invoices, estimates, work orders, customers, service tasks, purchase orders, timesheets, and so on. When we set out to expose Zuper to LLM agents through the Model Context Protocol (MCP), the</p>]]></description><link>https://labs.zuper.co/blog/how-we-built-the-zuper-mcp-intent-driven-testing-module-wise-coverage-and-closing-100-schema-gaps/</link><guid isPermaLink="false">6a4e17955a2dfd3abfb65db7</guid><dc:creator><![CDATA[Maniarasan Sivaseran]]></dc:creator><pubDate>Mon, 13 Jul 2026 11:16:24 GMT</pubDate><media:content url="https://labs.zuper.co/content/images/2026/07/MCP_cover-image.png" medium="image"/><content:encoded><![CDATA[<h1 id></h1><h2 id="the-problem-we-started-with">The problem we started with</h2><img src="https://labs.zuper.co/content/images/2026/07/MCP_cover-image.png" alt="How we built the Zuper MCP: intent-driven testing, module-wise coverage, and closing 100+ schema gaps"><p>Zuper is a field service management platform with around 30 core business domains &#x2014; jobs, invoices, estimates, work orders, customers, service tasks, purchase orders, timesheets, and so on. When we set out to expose Zuper to LLM agents through the Model Context Protocol (MCP), the naive shape was obvious: one MCP &quot;tool&quot; per REST endpoint. That gave us hundreds of surface points, and every one of them had to be right.</p><p>&quot;Right&quot; is a loaded word. A tool schema is right when:</p><ul><li>Every field it accepts is actually accepted by the API.</li><li>Every field the API requires is either in the schema or auto-derived.</li><li>The enums it advertises are the enums the backend enforces.</li><li>The request-body shape is what the backend expects.</li><li>The endpoint URL and HTTP method match reality.</li><li>The agent gets a useful error, not a generic 400, when it&apos;s wrong.</li></ul><p>Getting one tool right by hand is easy. Getting ~300 tools across ~30 modules right needed a different approach.</p><p>This is the story of how we built it, and what we learned along the way.</p><hr><h2 id="why-not-just-unit-tests">Why not just unit tests</h2><p>The instinct was: unit-test each tool with mocked HTTP. We didn&apos;t take that path, for three reasons.</p><p><strong>One, mocks lie.</strong>&#xA0;A unit test with a mocked API client passes when the tool&apos;s schema accepts the input and the mock returns a canned response. It says nothing about whether the real backend will accept that payload. We learned this the hard way when a &quot;green&quot; test suite shipped a tool that called&#xA0;<code>POST</code>&#xA0;on an endpoint that only responded to&#xA0;<code>PUT</code>. The backend&apos;s 404 was a real signal; the mock happily returned success.</p><p><strong>Two, the schemas evolve daily.</strong>&#xA0;The MCP layer is a translation between two shapes &#x2014; the agent-facing schema and the backend API contract. The backend gets updates from a different team on a different cadence. We needed tests that run against the real API, ideally on staging, so drift is caught immediately.</p><p><strong>Three, the questions are workflow-shaped.</strong>&#xA0;&quot;Can an agent create a job, attach line items, apply a per-line tax, and generate an invoice?&quot; is not one unit test. It&apos;s a chain of six calls with state flowing between them. That&apos;s what our users care about; that&apos;s what we needed to verify.</p><p>So we built&#xA0;<strong>intent-based tests</strong>.</p><hr><h2 id="what-an-intent-test-is">What an intent test is</h2><p>An intent is a plain-English business goal, encoded as a small object:</p><pre><code class="language-js">{
  id: &apos;CUST-C01a&apos;,
  module: &apos;Customers&apos;,
  category: &apos;create&apos;,
  intent: &apos;Create a customer with name and email&apos;,
  tool: &apos;create_customer&apos;,
  args: () =&gt; ({
    first_name: &apos;Acme&apos;,
    last_name: &apos;Corp&apos;,
    email: &apos;contact@acme.example&apos;,
  }),
  onSuccess: (result, state) =&gt; { state.new_customer_id = result.customer_id; },
  verify: {
    tool: &apos;get_customer&apos;,
    args: state =&gt; ({ customer_id: state.new_customer_id }),
    check: r =&gt; r.customer.email === &apos;contact@acme.example&apos;,
  },
}
</code></pre><p>Every intent has:</p><ul><li><strong><code>intent</code></strong>&#xA0;&#x2014; a human-readable sentence that reads like a Jira ticket title.</li><li><strong><code>tool</code>&#xA0;+&#xA0;<code>args(state)</code></strong>&#xA0;&#x2014; the tool to call and how to build its arguments from the shared session state.</li><li><strong><code>onSuccess</code></strong>&#xA0;&#x2014; a hook to stash the returned identifier for downstream tests.</li><li><strong><code>verify</code></strong>&#xA0;&#x2014; a follow-up read that confirms the write took effect. Not just &quot;did the tool return 200,&quot; but &quot;does the corresponding read tool show the field we wrote.&quot;</li></ul><p><code>args</code>&#xA0;is a function, not a value, because tests share state. Creating a customer produces a customer ID; later intents that create jobs or invoices for that customer consume it. The state object is threaded through the whole run.</p><p>Intents are organized by module and category (create / update / list_get) &#x2014; one file per business area, one array per file.</p><p>The verify round-trip matters more than the initial call. Many silent-failure modes we later fixed &#x2014; schema too loose, backend rejecting a subfield but returning 200, tax field silently dropped &#x2014; only showed up in the read-back.</p><hr><h2 id="where-1000-intents-came-from">Where 1000+ intents came from</h2><p>Writing test cases by hand doesn&apos;t scale to hundreds of tools. Writing test cases from imagination is worse &#x2014; you test the happy path and miss the edge cases that actually break in production. We needed a source of test intents that reflected how real people used the product, not how engineers thought they should.</p><p>We got there by pulling from two sources:</p><p><strong>Past support tickets and issue trackers.</strong>&#xA0;Every ticket the team had ever worked on described a real customer trying to do a real thing. &quot;Customer can&apos;t mark an invoice partially-paid from the draft state&quot; is a test intent. &quot;Field engineer&apos;s clock-in reflected on the wrong user&quot; is a test intent. &quot;Percentage discount not honored when applied at line-item level&quot; is a test intent. We walked the ticket history module by module, converted each recognizable customer flow into an intent, and folded them into the coverage suite. Every ticket that turned into an intent got a comment linking back so the next incident would find the regression test already there.</p><p><strong>Intent libraries per customer segment.</strong>&#xA0;Different customers use different subsets of the platform. An HVAC service company lives in jobs, service tasks, and invoices. A property management firm lives in properties, service contracts, and recurring jobs. A distribution business lives in purchase orders, material requests, and transfer orders. Testing &quot;does the tool work&quot; is not the same as &quot;does the tool work for the way this segment uses it.&quot; We built per-segment intent libraries by walking through each customer&apos;s actual workflows &#x2014; using their data model (product categories, custom fields, business units) as inputs to the intents.</p><p>The two sources compounded. Ticket-derived intents caught the bugs we&apos;d already been bitten by. Segment-derived intents caught the bugs we&apos;d otherwise ship into a new segment blind. Together they took the suite past 1000 intents.</p><p>A side benefit we didn&apos;t expect: the intent library became&#xA0;<strong>documentation for the tool</strong>. Product and solutions teams can read the intent titles and immediately see what the platform can do for a specific segment. &quot;Schedule a follow-up job for a customer with an existing service contract&quot; is more useful than any API doc.</p><p>The rule we settled on:&#xA0;<strong>new bugs get intents before they get fixes.</strong>&#xA0;Once a customer-reported issue is in the intent suite, it stays covered forever. Fix without an intent, and it comes back.</p><hr><h2 id="the-runner-field-strip-retry">The runner: field-strip retry</h2><p>Real APIs fail for surprising reasons. A schema evolves, an enum tightens, a required field starts erroring out. When that happens in a test suite, we don&apos;t want to abandon the whole intent; we want to isolate which field is the culprit.</p><p>So the runner has a&#xA0;<strong>field-strip retry loop</strong>. On tool error, it:</p><ol><li>Pattern-matches the error message for a suspect field name.</li><li>Removes that field from the arguments.</li><li>Retries the call.</li><li>Loops until success, or until every optional field has been stripped.</li></ol><p>At the end of the run it reports two verdicts per field:&#xA0;<strong>VERIFIED</strong>&#xA0;(the field was accepted and reflected in the verify read) or&#xA0;<strong>UNVERIFIED</strong>&#xA0;(the field was stripped during retry).</p><p>The point isn&apos;t to make failing tests pass. It&apos;s to produce a&#xA0;<strong>coverage map</strong>. When we saw one field marked UNVERIFIED across forty different tests, we knew the whole schema for that field had drifted from the backend &#x2014; one bug, not forty.</p><hr><h2 id="the-coverage-document-%E2%80%94-one-per-module">The coverage document &#x2014; one per module</h2><p>Each module owns two generated files:</p><ul><li><strong>A run report</strong>&#xA0;&#x2014; the summary. Every intent, PASS/FAIL/VERIFY_FAILED, which fields were stripped, which are UNVERIFIED.</li><li><strong>A failure guide</strong>&#xA0;&#x2014; each failure gets a stanza with the tool name, error message, stripped fields, likely cause, and a &quot;suggested fix&quot; line.</li></ul><p>These aren&apos;t just artifacts &#x2014; they became the primary planning surface. Every session started by reading the last run&apos;s failure guide. Fix, re-run, watch FAIL count drop, watch PASS count climb.</p><p>The pattern we settled into:</p><pre><code>Round 1: baseline run &#x2014; enumerate every failure.
Round 2: fix the top N failures, re-run just those tests.
Round 3: re-run the whole module. New failures? Different failures?
Round 4: expand coverage &#x2014; add intents for edge cases surfaced during fixes.
</code></pre><p>A concrete example: the tax and line-item audit. Round 1 showed several &quot;create estimate with ad-hoc tax&quot; intents as VERIFY_FAILED. Investigation revealed the backend requires a tax UID reference for every document-level tax entry &#x2014; it hard-rejects ad-hoc&#xA0;<code>{name, percent}</code>&#xA0;forms. We narrowed the schema to require the UID. Re-ran. Passed. Added Round-2 intents for line-item-level tax and markup. Passed. Added Round-3 intents for the update path, tax-exempt overrides, mixed inheritance. Passed. Coverage went from 4 tax intents to 22 in one week, all green.</p><p>The coverage doc is a ratchet: it only counts intents that VERIFY, and the verified count only goes up.</p><hr><h2 id="adversarial-verification-with-parallel-agents">Adversarial verification with parallel agents</h2><figure class="kg-card kg-image-card"><img src="https://labs.zuper.co/content/images/2026/07/parallel_agents.png" class="kg-image" alt="How we built the Zuper MCP: intent-driven testing, module-wise coverage, and closing 100+ schema gaps" loading="lazy" width="1536" height="1024" srcset="https://labs.zuper.co/content/images/size/w600/2026/07/parallel_agents.png 600w, https://labs.zuper.co/content/images/size/w1000/2026/07/parallel_agents.png 1000w, https://labs.zuper.co/content/images/2026/07/parallel_agents.png 1536w" sizes="(min-width: 720px) 720px"></figure><p>Half the bugs we caught weren&apos;t in the runner output. They were in the&#xA0;<em>schema itself</em>&#xA0;&#x2014; fields that looked plausible but weren&apos;t in the backend, enums that shipped extra values the backend would reject.</p><p>For those, the runner alone can&apos;t help &#x2014; a schema that never gets exercised by an intent test can be arbitrarily wrong and nobody notices. So we ran periodic audit passes.</p><p>The pattern:</p><ol><li>Spawn 4&#x2013;6 parallel research agents. Each takes a subset of modules.</li><li>Each agent reads the MCP tool file, the corresponding webapp form component, and the backend controller. It reports a table: field-by-field, what&apos;s in the tool vs the UI vs the backend.</li><li>Agent findings are&#xA0;<strong>claims</strong>, not truth. Every high-severity claim gets spot-verified against the actual source before it hits the audit doc.</li><li>Consolidate into a single module-wise gap doc.</li></ol><p>The reject-before-trust step is important. Agents are helpful and confidently wrong in equal measure. In one audit round, an agent claimed a listing tool used the wrong enum values. We opened the file &#x2014; the actual enum was correct; the agent had misread a docstring elsewhere in the same file. We rejected that claim in the write-up. In the same round, another agent flagged a deposit-recording tool as calling POST when the backend registered PUT. We opened the file &#x2014; real bug, six months old, silent behind an outer try/catch.</p><p><strong>Every claim gets grep-verified before it becomes a finding.</strong>&#xA0;This one habit cut the false-positive rate on our audit docs from ~30% to under 5%.</p><hr><h2 id="when-docs-arent-enough-browser-automation-as-a-discovery-tool">When docs aren&apos;t enough: browser automation as a discovery tool</h2><p>Roughly a third of the way through the project, we hit a wall: the internal API reference documents were incomplete. Some endpoints were undocumented. Others were documented but omitted fields the UI was actually sending. Some documented fields were flagged &quot;optional&quot; but were required in specific configurations. Without ground truth for those endpoints, we were guessing.</p><p>So we brought browser automation into the loop.</p><p>The pattern was simple: script the webapp&apos;s real workflow end-to-end &#x2014; log in, create a job, add products, set a tax, save, refresh &#x2014; and capture every network request. Each captured request gave us a real payload: exact field names, exact nesting, exact enum values, exact wrappers. When our schema disagreed with the payload, the schema was wrong.</p><p>A few things it exposed that we&apos;d have missed otherwise:</p><ul><li><strong>Undocumented approval hooks.</strong>&#xA0;A specific status transition sent an extra approval identifier the API reference didn&apos;t mention. The browser automation caught it on the first pass.</li><li><strong>Field-nesting quirks.</strong>&#xA0;The webapp nests certain settings (billing frequency, payment term) under a nested accounts object on save. Our tool was sending them flat. The API &quot;accepted&quot; both shapes but silently discarded the flat one. The captured payload made the correct shape visible.</li><li><strong>Company-config-gated behaviors.</strong>&#xA0;Some fields only appear in the payload when a company setting is on. Running the automation against two configurations side by side let us design a schema that handled either.</li></ul><p>Browser automation wasn&apos;t a replacement for the API docs or the backend source. It was the fastest way to answer &quot;what does the UI actually send?&quot; when the other two disagreed, and to catch the hidden third case both had missed. The reusable rule:&#xA0;<strong>when in doubt, capture the network traffic.</strong></p><hr><h2 id="the-module-dependency-matrix">The module dependency matrix</h2><p>Ordering modules by &quot;business criticality&quot; was the first cut. But criticality isn&apos;t the same as &quot;safe to fix first.&quot; A tool schema for jobs depends on customers existing, products existing, tax IDs existing, business units existing. Fix jobs first and half your tests fail because the entities they depend on aren&apos;t seeded.</p><p>So we built a&#xA0;<strong>module dependency matrix</strong>&#xA0;&#x2014; a per-module document mapping every upstream dependency:</p><pre><code>Customer domain
  &#x251C;&#x2500; Depends on: categories, organizations, tax groups, pricelists
  &#x2514;&#x2500; Depended on by: jobs, invoices, estimates, projects, contracts, ...

Job domain
  &#x251C;&#x2500; Depends on: customer, product, product category, job category,
  &#x2502;              user, team, business unit, service tasks, tax
  &#x2514;&#x2500; Depended on by: invoice, estimate, service report, timelog, ...
</code></pre><p>Roughly two dozen of these documents now live alongside the code, one per business domain. Each names its foreign-key relationships, the exact fields that carry the reference, and any known quirks (e.g. &quot;the job&apos;s property inherits from the customer if omitted, but only when the customer has a default property set&quot;).</p><p>Two immediate benefits:</p><p><strong>Testing order becomes deterministic.</strong>&#xA0;The seed script that populates the shared session state reads the dependency graph and creates entities in topological order. Customers before jobs, products before line items, tax before invoices. No more &quot;test failed because the customer ID is empty.&quot;</p><p><strong>Field audits become sharper.</strong>&#xA0;When we&apos;re auditing an invoice-creation tool, the customer dependency doc tells us which fields flow into the customer lookup, which fields the invoice inherits automatically, and which ones a manual override is expected. That&apos;s usually what the API docs leave implicit.</p><p>We also generated companion&#xA0;<strong>API reference documents</strong>&#xA0;&#x2014; one per module &#x2014; that consolidate every endpoint we call, its verified request shape (from network capture + backend source cross-check), and its response shape. These sit alongside the dependency matrix. The pair of documents &#x2014; dependency map + verified API reference &#x2014; became the input to every new intent-suite expansion and every audit round. If a claim in a coverage doc contradicts one of these two, the coverage doc gets updated first.</p><hr><h2 id="module-by-module-progression">Module-by-module progression</h2><p>We didn&apos;t try to fix all tools at once. We ordered modules by:</p><ol><li><strong>Business criticality</strong>&#xA0;(jobs first, then invoices and estimates, then service tasks, then material requests, then everything else).</li><li><strong>Intent-suite coverage</strong>&#xA0;(biggest suites first &#x2014; they surface the most bugs per hour).</li><li><strong>Cross-module dependency</strong>&#xA0;(fix customers before jobs, because job creation needs customer state).</li></ol><figure class="kg-card kg-image-card"><img src="https://labs.zuper.co/content/images/2026/07/19055c66-09fd-4c19-8ace-059fa08375ee.png" class="kg-image" alt="How we built the Zuper MCP: intent-driven testing, module-wise coverage, and closing 100+ schema gaps" loading="lazy" width="1536" height="1024" srcset="https://labs.zuper.co/content/images/size/w600/2026/07/19055c66-09fd-4c19-8ace-059fa08375ee.png 600w, https://labs.zuper.co/content/images/size/w1000/2026/07/19055c66-09fd-4c19-8ace-059fa08375ee.png 1000w, https://labs.zuper.co/content/images/2026/07/19055c66-09fd-4c19-8ace-059fa08375ee.png 1536w" sizes="(min-width: 720px) 720px"></figure><p>Per module, the loop was:</p><ol><li><strong>Baseline</strong>&#xA0;&#x2014; run current intents, capture the coverage report.</li><li><strong>Field audit</strong>&#xA0;&#x2014; parallel research pass to find missing or wrong fields vs the UI and the backend.</li><li><strong>Fix critical</strong>&#xA0;&#x2014; schema and endpoint corrections.</li><li><strong>Expand coverage</strong>&#xA0;&#x2014; new intents for the fields just added.</li><li><strong>Regression</strong>&#xA0;&#x2014; re-run the whole module; add any newly surfaced failures to the queue.</li></ol><p>For the jobs module alone, that loop produced dozens of field-level fixes and roughly doubled the passing intent count.</p><hr><h2 id="what-surprised-us">What surprised us</h2><p><strong>Silent overrides.</strong>&#xA0;Several endpoints happily accept fields they then ignore. The backend&apos;s tax calculator overwrites caller-supplied tax name and percent from the tax-master lookup, regardless of what the caller sent. Our MCP was faithfully passing through fields the backend would silently drop, giving agents the illusion of setting them. Fixing this meant narrowing schemas&#xA0;<em>below</em>&#xA0;what the backend would accept &#x2014; trading permissiveness for predictability.</p><p><strong>The unit-price / price schism.</strong>&#xA0;Jobs use one field name for line-item price. Invoices and estimates use a different one. A converter tool translates one to the other. No documentation of the flip anywhere. An agent using our tools cross-module has to know this by feel unless the tool descriptions call it out. We added the notes.</p><p><strong>Status enums that lie.</strong>&#xA0;One status-update tool accepted status values as write targets that the backend never allows to be set directly &#x2014; some are computed automatically from other actions, some are display-only. An agent that tried those transitions through MCP would get a generic 4xx and no clear signal. Narrowing the write enum was a one-line fix; finding the mismatch was hours of matrix-reading.</p><p><strong>The auto-derive trap.</strong>&#xA0;Some create tools mark fields optional even though the backend requires them &#x2014; because when the caller passes a parent entity, the tool auto-copies the required fields from that parent. The schema looks broken until you read the execute body. Documentation matters more than schema strictness once the tool starts doing derivation work.</p><hr><h2 id="the-tooling-that-kept-us-moving">The tooling that kept us moving</h2><ul><li><strong>A restart-between-chunks runner</strong>&#xA0;&#x2014; the upstream MCP session state degrades under sustained load; a chunked runner is a workaround, not a fix, but it kept the suite moving.</li><li><strong>A shared session-state file</strong>&#xA0;&#x2014; pre-seeded IDs for common entities (customer, product, tax, category), populated once via a seed script; every test run starts from it.</li><li><strong>An ID-prefix filter</strong>&#xA0;&#x2014; a CLI flag to re-run just one group of intents by ID prefix. Made the &quot;fix, run three tests, re-run all&quot; loop fast enough to stay in flow.</li><li><strong>An env-file credential loader</strong>&#xA0;&#x2014; no hardcoding, no accidental leaking of production keys into a staging suite.</li></ul><p>None of these are impressive individually. Together, they made large-scale coverage tractable.</p><hr><h2 id="what-wed-change-next-time">What we&apos;d change next time</h2><p><strong>Start with the coverage doc, not the tool.</strong>&#xA0;The first tools we built had shallow schemas because we hadn&apos;t scoped the UI form&apos;s field list yet. We ended up rebuilding several tools twice. Working from a coverage matrix &#x2014; every field in the UI form, every field in the backend schema, every field we plan to expose &#x2014; would have front-loaded the design work.</p><p><strong>Adopt a strict &quot;tighten before ship&quot; rule.</strong>&#xA0;Schemas are easy to loosen after the fact and painful to tighten. Every &quot;make it optional for now&quot; we deferred came back as a support ticket. Better to start narrow and open up on demand.</p><p><strong>Contract tests, not just integration tests.</strong>&#xA0;The intent suite exercises real endpoints, but it doesn&apos;t lock the backend response&#xA0;<em>shape</em>. A field rename on the backend silently breaks our verify checks. A layer of contract tests that snapshot response schemas would have caught two silent regressions we found only by luck.</p><p><strong>Backend-side gate for enums.</strong>&#xA0;Many enum bugs (statuses in write enums that shouldn&apos;t be, transition targets the backend won&apos;t accept) exist because the backend accepts one set of values as input and produces a different set as output. The backend should either publish the write-side matrix or the MCP should mirror the matrix explicitly.</p><hr><h2 id="where-the-project-stands">Where the project stands</h2><ul><li>Approaching 300 tools across roughly 30 modules</li><li>1000+ intent tests, sourced from past tickets and per-segment customer workflow libraries</li><li>Intent suites for the top business modules: jobs, invoices, estimates, customers, service tasks, material requests, timesheets, purchase orders</li><li>Live-API test runs on staging on demand</li><li>Per-module coverage docs updated on every run</li><li>Field-gap audits checked in with prioritized fix queues</li></ul><p>The core lesson is unglamorous but real:&#xA0;<strong>at scale, the test suite is the design tool.</strong>&#xA0;Intents make the requirements concrete. Coverage docs make the gaps visible. Round-by-round iteration makes the delta shippable. We didn&apos;t write hundreds of tools right on the first try. We wrote them approximately, and then let the tests tell us where they were wrong.</p><p>That, more than anything, is how the <strong>Zuper</strong> MCP got built.</p>]]></content:encoded></item></channel></rss>