Testing Multi-Tool MCP Apps: Tool Contracts, Workflows, and Disambiguation (July 2026)

Testing multi-tool MCP Apps across ChatGPT and Claude hosts.
Most MCP App examples start with one tool and one resource. That is enough for a demo, but production ChatGPT Apps and Claude Connectors usually have a tool set:
search_ordersget_order_detailspreview_refundapply_refundshow_orders_dashboardexport_orders_csv
Those tools share IDs, state, permissions, schemas, resource components, and model-facing descriptions. They also compete for the same user prompts. “Show me my orders” might mean list recent orders, search all orders, open a dashboard, or get details for an order already selected in the UI.
That is why multi-tool apps need more than unit tests. You need tests for the tool graph, the MCP metadata, the data contracts between tools, the UI states that follow each tool, and the model’s ability to choose the right tool.
TL;DR: Test multi-tool MCP Apps as a graph. First verify tools/list, resource links, annotations, and schemas. Then test runtime contracts between producer and consumer tools. Then test complete workflows with chained mcp.callTool() calls and inspector E2E tests. Finally, run evals for ambiguous prompts and no-tool prompts. Keep cheap deterministic tests in every pull request, then run evals and live host tests on slower gates.
Start with the Tool Graph
Before writing tests, write down the tool graph. It does not need a diagram. A compact table is enough.
| Tool | Produces | Consumes | Resource | Risk |
|---|---|---|---|---|
search_orders | orders[].id | query, filters | orders-list | read-only |
get_order_details | order.id, lineItems[] | orderId | order-detail | read-only |
preview_refund | refundToken, preview rows | orderId, line item IDs | refund-review | read-only preview |
apply_refund | refund receipt | refundToken | none or receipt UI | write, possibly destructive |
show_orders_dashboard | dashboard seed data | workspace ID | orders-dashboard | read-only |
This table tells you what to test:
- Producer outputs such as
orders[].id. - Consumer inputs such as
orderId. - Resource components that render more than one tool result.
- Required tool order, such as preview before apply.
- Side-effect metadata, such as
readOnlyHintanddestructiveHint. - Model-facing ambiguity, such as search versus dashboard.
When the graph is unclear, the tests will be unclear. If two tools have the same job, merge them or rename them before writing evals.
Test Discovery Before Behavior
The first test layer should not call business logic at all. It should ask the MCP server what it exposes.
For multi-tool apps, tools/list is a contract. Hosts use it to learn tool names, descriptions, input schemas, annotations, output schemas, resource links, and visibility. ChatGPT plugin submission also scans deployed MCP metadata, so a metadata-only regression can break review before your handler runs.
Add a conformance test:
import { expect, test } from 'sunpeak/test';
test('order tools expose complete MCP metadata', async ({ mcp }) => {
const tools = await mcp.listTools();
const byName = Object.fromEntries(tools.map((tool) => [tool.name, tool]));
for (const name of [
'search_orders',
'get_order_details',
'preview_refund',
'apply_refund',
]) {
expect(byName[name], `${name} is registered`).toBeTruthy();
expect(byName[name].title ?? byName[name].annotations?.title).toBeTruthy();
expect(byName[name].description.length).toBeGreaterThan(40);
expect(byName[name].inputSchema).toBeTruthy();
expect(byName[name].annotations).toBeTruthy();
}
expect(byName.search_orders.annotations?.readOnlyHint).toBe(true);
expect(byName.preview_refund.annotations?.readOnlyHint).toBe(true);
expect(byName.apply_refund.annotations?.readOnlyHint).toBe(false);
expect(byName.apply_refund.annotations?.destructiveHint).toBe(true);
});
This catches:
- Missing tool exports.
- Weak descriptions.
- Broken input schemas.
- Missing annotations.
- A write tool accidentally marked read-only.
- A renamed tool that evals and host submission still expect.
Pair this with a resource check for UI-capable tools:
test('UI tools point at readable resources', async ({ mcp }) => {
const tools = await mcp.listTools();
const uiTools = tools.filter((tool) => tool._meta?.ui?.resourceUri);
for (const tool of uiTools) {
const uri = tool._meta.ui.resourceUri;
expect(uri).toMatch(/^ui:\/\//);
const resource = await mcp.readResource(uri);
expect(resource.mimeType).toBe('text/html;profile=mcp-app');
expect(resource.text.length).toBeGreaterThan(100);
}
});
If discovery fails, do not continue to browser tests. The host cannot render what it cannot discover.
Contract Tests Between Tools
A contract test verifies that one tool returns the data another tool needs.
For example, search_orders produces IDs, and get_order_details consumes one of those IDs:
import { expect, test } from 'sunpeak/test';
test('search_orders returns IDs accepted by get_order_details', async ({ mcp }) => {
const search = await mcp.callTool('search_orders', {
query: 'refund',
limit: 5,
});
expect(search.isError).toBeFalsy();
expect(search.structuredContent.orders.length).toBeGreaterThan(0);
const firstOrder = search.structuredContent.orders[0];
expect(firstOrder.id).toEqual(expect.any(String));
const details = await mcp.callTool('get_order_details', {
orderId: firstOrder.id,
});
expect(details.isError).toBeFalsy();
expect(details.structuredContent.order.id).toBe(firstOrder.id);
});
This is different from a unit test. It calls real tool handlers through the MCP server, so it catches runtime drift at the protocol boundary.
When a tool returns structuredContent, declare an outputSchema and test the result against it. TypeScript types help inside your codebase. outputSchema helps hosts, tests, and review tooling understand the shape that crosses the MCP boundary.
Keep contract tests narrow. Test the fields that another tool, resource, or model-visible workflow depends on:
- IDs and cursors.
- Status enums.
- Review or approval tokens.
- Resource URIs.
- Required summary fields.
- Public labels the model uses in follow-up turns.
Do not assert every cosmetic field. Over-specific tests make harmless UI edits expensive.
Workflow Tests for Tool Sequences
Workflow tests chain tool calls in the order a user would trigger them. They prove the graph works as a system.
test('search to details to refund preview to apply workflow', async ({ mcp }) => {
const search = await mcp.callTool('search_orders', {
query: 'Acme headset',
});
const order = search.structuredContent.orders[0];
const details = await mcp.callTool('get_order_details', {
orderId: order.id,
});
const lineItem = details.structuredContent.order.lineItems[0];
const preview = await mcp.callTool('preview_refund', {
orderId: order.id,
lineItemIds: [lineItem.id],
});
expect(preview.isError).toBeFalsy();
expect(preview.structuredContent.refundToken).toEqual(expect.any(String));
const apply = await mcp.callTool('apply_refund', {
refundToken: preview.structuredContent.refundToken,
});
expect(apply.isError).toBeFalsy();
expect(apply.structuredContent.receipt.status).toBe('submitted');
});
Also test broken sequences:
test('apply_refund rejects missing preview token', async ({ mcp }) => {
const result = await mcp.callTool('apply_refund', {
refundToken: 'missing-token',
});
expect(result.isError).toBe(true);
expect(result.content?.[0]?.text).toMatch(/preview/i);
});
The error path matters because models and users do not always follow the happy path. A tool should explain what is missing and how to recover.
Server Instructions Are Part of the Contract
Multi-tool rules often belong in MCP server instructions:
For refund workflows, call preview_refund before apply_refund. Only call apply_refund after the user approves the preview in the app UI. Use search_orders for broad lookups and get_order_details only when an order ID is known.
Test the initialize metadata:
test('server instructions include refund workflow order', async ({ mcp }) => {
const info = await mcp.initialize();
expect(info.instructions).toContain('preview_refund before apply_refund');
expect(info.instructions.length).toBeLessThan(600);
});
Then add eval cases for that rule:
{
name: 'previews before refund',
prompt: 'Refund the Acme headset order',
expect: { tool: 'preview_refund' },
}
If the eval still calls apply_refund, the instruction is not strong enough, the tool names are too tempting, or the write tool should be hidden until the UI supplies a token.
App-Only Tools Need Tests Too
Interactive MCP Apps often have tools the model should not call directly. A model-visible tool opens the UI. The UI then calls app-only tools for pagination, filtering, export preview, draft updates, or validation.
Example:
- Model-visible:
show_orders_dashboard - App-only:
load_next_orders_page - App-only:
filter_orders_dashboard - App-only:
preview_export_csv
Test both sides:
tools/listexposes the right visibility metadata.- The rendered UI can call the app-only tool.
test('pagination tool is app-only', async ({ mcp }) => {
const tools = await mcp.listTools();
const loadNext = tools.find((tool) => tool.name === 'load_next_orders_page');
expect(loadNext?._meta?.ui?.visibility).toEqual(['app']);
});
Then render and click:
test('dashboard loads the next page from the app UI', async ({ inspector }) => {
const result = await inspector.renderTool('show_orders_dashboard', {
workspaceId: 'ws_123',
});
const app = result.app();
await app.getByRole('button', { name: 'Next page' }).click();
await expect(app.getByText('Page 2')).toBeVisible();
});
The important point: app-only tools still need auth, validation, rate limits, and tests. Hiding a tool from model routing is not a security boundary.
Shared Resource Tests
Some tools render the same resource with different result shapes. A task card might render data from get_task, create_task, and update_task. A review UI might render preview_change, approve_change, and reject_change.
Test each tool path:
test('review resource renders preview_change output', async ({ inspector }) => {
const result = await inspector.renderTool('preview_change', {
changeId: 'change_123',
});
const app = result.app();
await expect(app.getByRole('heading', { name: 'Review change' })).toBeVisible();
await expect(app.getByText('3 files changed')).toBeVisible();
});
test('review resource renders approve_change output', async ({ inspector }) => {
const result = await inspector.renderTool('approve_change', {
reviewToken: 'review_test',
});
const app = result.app();
await expect(app.getByText('Approved')).toBeVisible();
});
Shared resources should handle missing optional fields, empty arrays, permission-denied states, expired tokens, and old result shapes during rollout. If a resource only renders one producer’s payload, it is not really shared.
Simulations for Multi-Step UI
Simulation files let you pin a conversation state without calling real tools. For multi-tool workflows, use multi-message simulations so the inspector shows the full user path.
{
"messages": [
{
"role": "user",
"content": "Find the order for Acme headset"
},
{
"role": "assistant",
"content": "I found one matching order.",
"toolCalls": [
{
"tool": "search_orders",
"toolInput": { "query": "Acme headset" },
"toolResult": {
"structuredContent": {
"orders": [{ "id": "ord_123", "label": "Acme headset", "total": 249 }]
}
}
}
]
},
{
"role": "assistant",
"content": "Here is the refund preview.",
"toolCalls": [
{
"tool": "preview_refund",
"toolInput": { "orderId": "ord_123", "lineItemIds": ["li_1"] },
"toolResult": {
"structuredContent": {
"refundToken": "refund_test",
"amount": 249,
"requiresApproval": true
}
}
}
]
}
]
}
Use simulations for UI and manual inspection. Use mcp.callTool() tests for handler contracts. Mixing the two tends to hide bugs because fixtures can drift from real handler output.
Disambiguation Evals
Evals test the model-facing layer: names, descriptions, schemas, annotations, server instructions, and app state that the model can see.
For a multi-tool app, include:
- Direct prompts for each tool.
- Ambiguous prompts where two tools are plausible.
- Follow-up prompts that depend on prior state.
- No-tool prompts that should not call your server.
- Negative prompts that should refuse a write or ask for clarification.
import { expect } from 'vitest';
import { defineEval } from 'sunpeak/eval';
export default defineEval({
cases: [
{
name: 'lists recent orders',
prompt: 'Show me my recent orders',
expect: { tool: 'search_orders' },
},
{
name: 'gets details when order ID is known',
prompt: 'Open order ord_123',
expect: {
tool: 'get_order_details',
args: { orderId: 'ord_123' },
},
},
{
name: 'previews before write',
prompt: 'Refund the Acme headset order',
expect: { tool: 'preview_refund' },
},
{
name: 'does not call orders tool for unrelated question',
prompt: 'Explain how sales tax works in Texas',
expect: { tool: null },
},
],
});
When evals fail, inspect what the model called instead.
- Wrong tool: rename tools, sharpen descriptions, or merge overlapping tools.
- Missing argument: improve field descriptions and add required schema fields.
- Unsafe write call: update server instructions and make the write tool require a preview token.
- Too many tool calls: move broad guidance out of each tool and into server instructions.
Do not fix eval failures by weakening the assertion until it passes. The eval is telling you the model contract is unclear.
Cross-Host Checks
ChatGPT Apps and Claude Connectors both use MCP tools, but host behavior can differ. A multi-tool workflow may pass in one host and feel wrong in another because of confirmation UI, display modes, auth state, metadata refresh, or model routing.
For deterministic CI, use the local inspector:
- Render the same resource under ChatGPT and Claude host replicas.
- Test light and dark themes.
- Test inline and fullscreen display modes when supported.
- Test app-only tool calls from the iframe.
- Test missing auth, denied permissions, empty data, and tool errors.
Save live host tests for deployed behavior:
- ChatGPT plugin metadata scans and versioned submissions.
- Claude custom connector reachability.
- OAuth redirect and refresh.
- Real model tool selection with production metadata.
- Mobile or desktop host differences.
The live path should be small because it is slower, account-dependent, and sometimes costs credits. The local path should do most of the work.
CI Plan
Put the tests on different gates:
| Gate | Tests | Runs |
|---|---|---|
| Pull request | unit, conformance, contract, workflow, inspector E2E | every PR |
| Main branch | visual tests, focused evals | after merge |
| Release candidate | full eval set, live ChatGPT and Claude smoke tests | before publish |
| Scheduled | eval drift checks and live host smoke tests | daily or weekly |
For GitHub Actions, keep model API keys out of the default PR job:
name: Test
on:
pull_request:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm test
evals:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm test:eval
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
Adjust commands for your project. In a sunpeak project, pnpm test can run unit and E2E layers, while evals and live tests stay on separate opt-in gates.
A Multi-Tool Testing Checklist
Before shipping a multi-tool MCP App, verify:
- Every tool appears in
tools/listwith a stable name, title, description, input schema, and annotations. - Tools that return
structuredContentdeclare and satisfyoutputSchema. - UI-capable tools point at readable
ui://resources. - App-only tools are hidden from normal model routing and tested through the UI.
- Producer tools return IDs, cursors, tokens, and summaries that consumer tools can use.
- Workflow tests cover the main happy path and at least one broken sequence.
- Server instructions document required order for cross-tool workflows.
- Evals cover ambiguous prompts, no-tool prompts, and write-safety prompts.
- Shared resources render every tool result shape they receive.
- Live host tests cover only the deployed behavior local tests cannot prove.
Where sunpeak Fits
You can build all of this with the MCP SDK, Playwright, a test runner, and your own eval harness. The challenge is keeping the layers connected as the app grows.
sunpeak puts the local inspector, MCP fixture, simulation files, Playwright E2E tests, visual tests, evals, live host checks, and CI scaffolding in one workflow. That matters most for multi-tool apps because the bug is rarely in one file. It is usually a mismatch between the tool list, server instructions, tool result, resource UI, app-only action, and model routing.
Start by testing the tool graph. Then test the workflows. Then test whether models can find the right entry point. That is the path from “each tool works” to “the app works.”
Get Started
npx sunpeak newFurther Reading
- Integration testing MCP Apps - protocol tests for tools, resources, annotations, and _meta
- MCP App evals - test model tool selection and argument extraction
- MCP server instructions - guide cross-tool workflows for ChatGPT and Claude
- Testing MCP tool annotations - validate readOnlyHint, destructiveHint, and openWorldHint
- MCP App outputSchema - validate structuredContent contracts
- MCP App CI/CD with GitHub Actions - split cheap tests from evals and live checks
- Cross-host testing MCP Apps - verify ChatGPT and Claude runtime differences
- sunpeak testing framework
- MCP App framework
- OpenAI Apps SDK MCP server guide
- OpenAI Apps SDK reference
- MCP Tools specification
Frequently Asked Questions
How do I test MCP Apps with multiple tools?
Test multi-tool MCP Apps in layers. Start with tools/list conformance tests that verify every tool is registered with a title, description, input schema, outputSchema where useful, annotations, and resource links. Add contract tests for shared structuredContent shapes. Add workflow tests that chain mcp.callTool() calls. Add inspector E2E tests for rendered multi-step UI states. Add evals for prompts where the model may choose the wrong tool.
What is a tool graph in an MCP App?
A tool graph is the relationship between tools, resources, and data contracts. For example, search_orders produces order IDs, get_order_details consumes those IDs, preview_refund creates a review token, and apply_refund consumes that token after approval. Writing the graph down makes it clear which contracts need tests and which tool sequences should appear in server instructions.
What is a tool contract test for MCP Apps?
A tool contract test verifies that a tool returns the fields its downstream consumers need. If one tool returns results[].id and another tool expects orderId, the contract test should fail before the workflow reaches a real host. The strongest contract tests call tools through the MCP server, validate structuredContent against outputSchema, and assert only the consumer-required fields.
How do I test model tool selection when multiple tools overlap?
Use evals. Send realistic prompts to the target model or model family, expose the same MCP tool metadata your app publishes, and assert the selected tool and required arguments. Include no-tool cases, ambiguous prompts, short prompts, and follow-up prompts that depend on previous state. Run evals multiple times per case because tool choice can vary.
How do server instructions help multi-tool MCP Apps?
Server instructions help when a rule applies across tools, such as search before details, preview before apply, or open the UI before using app-only tools. Keep instructions short and factual. Test the initialize response and add eval cases for the workflow rule, because instructions are model-facing metadata that can change tool routing.
How do I test app-only tools in MCP Apps?
App-only tools should be hidden from normal model routing and callable only from the resource UI. Test their visibility metadata in tools/list, then use inspector E2E tests to click the UI control that calls the app-only tool. Also test that model-facing tools still return useful content when the host cannot render the UI.
What breaks most often in multi-tool MCP Apps?
The common failures are output shape drift, wrong tool annotations, vague tool names, missing server instructions for required order, app state assumptions, shared resources that only handle one result shape, and eval regressions after a model or host changes. Put each failure mode in a different test layer so the failure message points to the right fix.
How should multi-tool MCP App tests run in CI?
Run conformance, contract, workflow, and inspector E2E tests on every pull request because they are deterministic and do not need host accounts. Run evals on main, release branches, a schedule, or manual triggers because they call model APIs. Run live ChatGPT or Claude tests only for deployed host behavior such as OAuth, tool routing, plugin metadata, and iframe rendering.