Complete API documentation for @gleanwork/mcp-server-tester.
| Import from | Contents | Stability |
|---|---|---|
@gleanwork/mcp-server-tester |
Fixtures, matchers and validators, the MCP client, config, datasets with runEvalDataset and runEvalCase, judges, conformance, Agent Skills |
Stable |
@gleanwork/mcp-server-tester/fixtures/mcp |
test and assertions with the MCP fixtures and matchers |
Stable |
@gleanwork/mcp-server-tester/fixtures/mcpAuth |
Auth fixtures | Stable |
@gleanwork/mcp-server-tester/reporters/mcpReporter |
The MCP reporter | Stable |
@gleanwork/mcp-server-tester/evals |
The evaluation framework: eval configs, evals and batches, extension definition types, metrics, result stores, comparisons, tool optimizations | Stable, except the environment interfaces (below) |
@gleanwork/mcp-server-tester/auth |
Low-level OAuth: discovery, token storage, client credentials | Stable |
@gleanwork/mcp-server-tester/experimental/clients |
Desktop-run metadata types, Cowork settings and audit, client plugins | Experimental: may change between minor versions |
@gleanwork/mcp-server-tester/types |
The root's shared types on their own, without runtime code | Stable |
The ./evals, ./auth and ./experimental/clients subpaths are ESM only, and CommonJS code cannot require them. The ESM root and those three subpaths share one copy of the library, so anything registered through one is visible through the others. The CommonJS root is a separate copy; don't mix it with ESM imports of the subpaths. Optional desktop-client fields on root result types (such as EvalCaseResult.clientMetadata) are typed from ./experimental/clients and share its stability.
The environment interfaces in ./evals (EnvironmentDefinition, Environment, EnvironmentContext, EnvironmentKeep, Machine, WorkerChannel, the Shard* types, ShardTokens, TrialKey, machineEnvironment and dockerEnvironment) can change between 2.0 prereleases, until the mst.shard/v1 contract is frozen (ADR 0004).
- Entry points
- Fixtures
- Authentication
- Eval Functions
- Programmatic Validators
- Playwright Matchers
- Text Utilities
- Judge Functions
- Conformance Functions
- Protocol Helpers
Plugins whose extensions this project's tests use, such as a judge referenced as toPassToolJudge({ judge: 'acme/judge/completeness' }). Set it in a project's use block or with test.use({ mcpPlugins: [acme] }). The mcp and mcpClient fixtures install them. See Plugins.
Install plugin objects for code that calls validators or matchers outside runEvalDataset, runEvalCase, an eval, or the mcp fixture (all of which install the plugins you pass them). Validates every plugin first; installing the same plugin again is a no-op, and a different plugin claiming a loaded namespace throws. Returns the validated plugins.
Raw MCP SDK client from @modelcontextprotocol/client (MCP TypeScript SDK v2).
test('use raw client', async ({ mcpClient }) => {
const tools = await mcpClient.listTools();
const result = await mcpClient.callTool({ name: 'tool_name', arguments: { ... } });
});High-level test API with helper methods.
/**
* High-level API for interacting with MCP servers in tests
*
* This interface wraps the raw MCP Client with test-friendly methods
*/
export interface MCPFixtureApi extends MCPFixtureExtensions {
/**
* The underlying MCP client (for advanced usage)
*/
client: Client;
/**
* Authentication type used for this test session
*/
authType: AuthType;
/**
* Playwright project name for this test session
*/
project?: string;
/**
* The protocol this connection requested and negotiated, e.g.
* `{ requested: '2026-07-28', negotiated: '2026-07-28', era: 'modern' }`.
* Use it to skip era-specific tests:
* `test.skip(mcp.protocol.era !== 'modern')`.
*/
readonly protocol: MCPProtocolInfo;
/**
* Lists all available tools from the MCP server
*
* @returns Array of tool definitions
*/
listTools(): Promise<Array<Tool>>;
/**
* Calls a tool on the MCP server
*
* @param name - Tool name
* @param args - Tool arguments
* @returns Tool call result
*/
callTool<TArgs extends Record<string, unknown> = Record<string, unknown>>(
name: string,
args: TArgs
): Promise<CallToolResult>;
/**
* Gets information about the connected server
*/
getServerInfo(): {
name?: string;
version?: string;
} | null;
}List all tools available from the MCP server.
Returns: Promise<Array<Tool>>
const tools = await mcp.listTools();
console.log(tools.map((t) => t.name));Call a tool by name with arguments.
Parameters:
name: string- Tool nameargs: TArgs- Tool arguments
Returns: Promise<CallToolResult>
const result = await mcp.callTool('get_weather', { city: 'London' });Get server information (name, version).
Returns: { name?: string; version?: string } | null
const info = mcp.getServerInfo();
console.log(info?.name, info?.version);The protocol the connection requested and negotiated: { requested, negotiated, era }, e.g. { requested: '2026-07-28', negotiated: '2026-07-28', era: 'modern' }. See Protocol Versions.
The server/discover result on 2026-07-28 connections, null on legacy connections.
Returns: Promise<DiscoverResult | null>
resources/list (all pages) and resources/read.
Returns: Promise<Resource[]> / Promise<ReadResourceResult>
Send any request, such as an extension method, and validate the result against a Standard Schema (for example a Zod schema).
const result = await mcp.request(
'skills/list',
{},
z.object({ skills: z.array(z.object({ uri: z.string() })) })
);Agent Skills (SEP-2640) helpers: supported(), settings(), list(), get(uri), and read(uri, { entry?, verify? }), which verifies digest, size, and frontmatter against the skill's entry. See Agent Skills.
const [entry] = await mcp.skills.list();
const skill = await mcp.skills.read(entry!.uri);
expect(skill.verified).toBe(true);Builds discover, listResources, readResource, request, and skills for a client. Use it (with getProtocolInfo(client) for protocol) when you implement MCPFixtureApi yourself.
Creates an MCPFixtureApi wrapper around a raw MCP Client. Use this when you need manual fixture setup — for example in custom fixture hierarchies, non-Playwright test runners (Vitest, Jest), or when composing with other lifecycle logic.
For the standard Playwright use case, prefer importing test and mcp from @gleanwork/mcp-server-tester/fixtures/mcp, which wires this up automatically.
Parameters:
client: Client— MCP client created viacreateMCPClientForConfig(). A client created another way works for calls, butmcp.protocolreports the default requested setting, and conformance probes and wire-level checks skip, because MST didn't record how it connected.testInfo?: TestInfo— Optional PlaywrightTestInfo. When provided, operations are wrapped intest.step()and attachments are created for the MCP reporteroptions?: MCPFixtureOptions— Optional configuration
MCPFixtureOptions:
| Field | Type | Default | Description |
|---|---|---|---|
authType |
'oauth' | 'api-token' | 'none' |
'none' |
Authentication type for this session |
project |
string |
— | Playwright project name (for filtering/grouping in the reporter) |
callTimeoutMs |
number |
30000 |
Timeout in milliseconds for MCP operations |
Returns: MCPFixtureApi
import {
createMCPFixture,
createMCPClientForConfig,
closeMCPClient,
} from '@gleanwork/mcp-server-tester';
import { test as base } from '@playwright/test';
import type { MCPFixtureApi } from '@gleanwork/mcp-server-tester';
const test = base.extend<{ mcp: MCPFixtureApi }>({
mcp: async ({}, use, testInfo) => {
const client = await createMCPClientForConfig(config);
const api = createMCPFixture(client, testInfo, { authType: 'api-token' });
await use(api);
await closeMCPClient(client);
},
});
// Non-Playwright usage (no reporter attachments)
const client = await createMCPClientForConfig(config);
const api = createMCPFixture(client);
const tools = await api.listTools();For comprehensive authentication documentation, see the Authentication Guide.
import {
createTokenAuthHeaders,
validateAccessToken,
isTokenExpired,
isTokenExpiringSoon,
} from '@gleanwork/mcp-server-tester';Create HTTP headers with Authorization header.
Parameters:
accessToken: string- Access tokentokenType?: string- Token type (default:'Bearer')
Returns: Record<string, string>
const headers = createTokenAuthHeaders(process.env.MCP_ACCESS_TOKEN);
// { Authorization: 'Bearer eyJ...' }Validate that an access token is present and non-empty.
Parameters:
accessToken: string | undefined- Token to validate
Throws: Error if token is missing or empty
Check if a JWT token appears to be expired.
Parameters:
accessToken: string- JWT token
Returns: boolean
Check if a token will expire within the buffer time.
Parameters:
expiresAt: number | undefined- Expiration timestamp in millisecondsbufferMs?: number- Buffer time (default:60000= 1 minute)
Returns: boolean
import { PlaywrightOAuthClientProvider } from '@gleanwork/mcp-server-tester';Implements the MCP SDK's OAuthClientProvider interface with file-based storage.
const provider = new PlaywrightOAuthClientProvider({
storagePath: 'playwright/.auth/mcp-oauth-state.json',
redirectUri: 'http://localhost:3000/oauth/callback',
clientId: process.env.MCP_OAUTH_CLIENT_ID,
clientSecret: process.env.MCP_OAUTH_CLIENT_SECRET,
});import { test } from '@gleanwork/mcp-server-tester/fixtures/mcpAuth';
test('uses auth provider', async ({ mcpAuthProvider }) => {
// mcpAuthProvider is configured from environment variables
});interface MCPAuthConfig {
accessToken?: string;
oauth?: MCPOAuthConfig;
}
interface MCPOAuthConfig {
serverUrl: string;
scopes?: string[];
resource?: string;
authStatePath?: string;
clientId?: string;
clientSecret?: string;
redirectUri?: string;
}Load an eval dataset from a JSON file.
Parameters:
path: string- Path to dataset JSON fileoptions?: objectvalidate?: boolean- Validate the dataset against the schema (default:true)
Returns: Promise<EvalDataset>
const dataset = await loadEvalDataset('./data/evals.json');Run an eval dataset. Assertions are defined per-case in the dataset's assertions blocks.
Parameters:
options: EvalRunnerOptionsdataset: EvalDataset- Dataset to runplugins?: readonly Plugin[]- Plugins whose extensions (for exampleacme/judge/completenessjudges) the cases usestopOnFailure?: boolean- Stop on first failure (default:false)onCaseComplete?: (result: EvalCaseResult) => void- Callback after each case completesonTrialComplete?: (result: EvalCaseResult, trial: number) => void- Callback after each trial of a case, with the trial's result and its number from 0, before the case'sonCaseCompleteconcurrency?: number- Max parallel cases (default:1= sequential)client?: string,model?: string,clientOptions?: ClientOptions- The client cases run on, its model and options; a case's own fields change them. Outside an eval, cases run onmst, on the test's MCP connection (default client:'mst')defaultTrials?: number- Default trial count for client cases (default:1)defaultPassThreshold?: number- DefaultpassThresholdfor client cases that don't set one (default:1)defaultJudgeReps?: number- Default judge evaluation count per case (default:1)filterTags?: string[]- Only run cases whosetagscontain at least one matchsaveResultsTo?: string- Save run results to file for baseline comparisonomitResponsesFromBaseline?: boolean- Strip responses from saved baseline (default:true)baselineResultsFrom?: string- Load baseline file for regression detectiontoolOverrides?: ToolOverrideVariant- Runtime tool metadata overrides for tool optimizationsjudgeModel?: string- Judge model identifier recorded in run metadatareporting?: 'playwright' | 'none'-'none'when the caller reports results itself (evals do): no suggestion to passtestInfo(default:'playwright')
context: EvalContextmcp: MCPFixtureApi- MCP fixture APItestInfo?: TestInfo- Playwright test info (required for snapshot support)expect?: ExpectType- Playwright expect function (required for snapshot support)
Returns: Promise<EvalRunnerResult>
const result = await runEvalDataset(
{ dataset }, // options — what to run and how
{ mcp, testInfo } // context — Playwright fixtures from your test
);
console.log(`Passed: ${result.passed}/${result.total}`);Runtime tool overrides let you test alternate tool descriptions or input schemas without editing the eval dataset or MCP server source. Tool names are canonical server tool names; v1 does not support renames.
const variant = {
id: 'search-description-v2',
tools: {
search: {
description:
'Search internal company documents, policies, wiki pages, and announcements.',
inputSchema: {
type: 'object',
properties: {
query: {
type: 'string',
description: 'Natural language document or policy query.',
},
},
required: ['query'],
},
},
},
};
const baseline = await runEvalDataset(
{ dataset, defaultTrials: 10 },
{ mcp, testInfo }
);
const candidate = await runEvalDataset(
{
dataset,
defaultTrials: 10,
toolOverrides: variant,
},
{ mcp, testInfo }
);
console.log(candidate.metadata?.toolVariantId);Use compareEvalRuns() to summarize the completed baseline and candidate runs:
import { compareEvalRuns } from '@gleanwork/mcp-server-tester/evals';
const comparison = compareEvalRuns({
baseline,
candidate,
labels: {
baseline: 'baseline',
candidate: variant.id,
},
});
console.log(`Pass-rate delta: ${comparison.deltaPassRate}`);
console.log(`Improved cases: ${comparison.improvedCases.length}`);
console.log(`Regressed cases: ${comparison.regressedCases.length}`);interface ToolOverrideVariant {
id: string;
description?: string;
tools: Record<
string,
{
description?: string;
inputSchema?: Record<string, unknown>;
}
>;
}Compare two completed eval runs. This is a pure utility: it does not run evals, read or write baselines, call LLMs, or mutate datasets.
Parameters:
options: CompareEvalRunsOptionsbaseline: EvalRunnerResult- Baseline run resultcandidate: EvalRunnerResult- Candidate run resultlabels?: { baseline?: string; candidate?: string }- Optional display labels
Returns: EvalRunComparisonResult
const comparison = compareEvalRuns({
baseline,
candidate,
});The result includes pass-rate deltas, optional tool precision/recall/F1 deltas, and case buckets:
improvedCases- failed in baseline, passed in candidateregressedCases- passed in baseline, failed in candidateunchangedPasses- passed in both runsunchangedFailures- failed in both runsmissingFromBaseline- case exists only in candidatemissingFromCandidate- case exists only in baseline
It also has warnings: string[], which flags runs that negotiated different MCP protocol eras or revisions (from metadata.protocol).
External result storage persists eval runs, reporter runs, and comparison artifacts as JSON. GCS is the first built-in cloud provider.
type StoredArtifactKind =
| 'eval-runner-result'
| 'eval-run-summary'
| 'eval-run-comparison';
interface EvalResultStore {
saveArtifact<T>(artifact: StoredEvalArtifact<T>): Promise<void>;
loadArtifact<T>(
kind: StoredArtifactKind,
id: string
): Promise<StoredEvalArtifact<T>>;
loadLatestArtifact<T>(
kind: StoredArtifactKind
): Promise<StoredEvalArtifact<T> | null>;
listArtifacts(
kind: StoredArtifactKind,
options?: { limit?: number }
): Promise<StoredArtifactSummary[]>;
}Create a store from config:
import { createEvalResultStore } from '@gleanwork/mcp-server-tester/evals';
const store = createEvalResultStore({
provider: 'gcs',
bucket: 'my-mcp-eval-results',
prefix: 'my-server/main',
});runEvalDataset() accepts store-backed baseline references in addition to local
file paths:
await runEvalDataset(
{
dataset,
resultStore: store,
baselineResultsFrom: { store: true, ref: 'latest' },
saveResultsTo: { store: true, ref: { id: 'candidate-run' } },
},
{ mcp, testInfo }
);Stored runs can be used with compareEvalRuns():
import {
compareEvalRuns,
loadStoredEvalRunnerResult,
saveEvalRunComparison,
} from '@gleanwork/mcp-server-tester/evals';
const baseline = await loadStoredEvalRunnerResult(store, { id: 'baseline' });
const candidate = await loadStoredEvalRunnerResult(store, { id: 'candidate' });
const comparison = compareEvalRuns({
baseline: baseline.data,
candidate: candidate.data,
});
await saveEvalRunComparison({ store, comparison, id: 'candidate-comparison' });Result Structure:
/**
* Overall result of running an eval dataset
*/
export interface EvalRunnerResult {
/**
* Total number of cases
*/
total: number;
/**
* Number of passing cases
*/
passed: number;
/**
* Number of failing cases
*/
failed: number;
/**
* Individual case results
*/
caseResults: Array<EvalCaseResult>;
/**
* Overall execution time in milliseconds
*/
durationMs: number;
/**
* Difference between current pass rate and baseline pass rate.
* Positive = improvement, negative = regression.
* Only present when `baselineResultsFrom` was provided.
*/
deltaPassRate?: number;
/**
* Number of cases that regressed: passed in baseline, failed now.
* Only present when `baselineResultsFrom` was provided.
*/
regressions?: number;
/**
* Number of cases that improved: failed in baseline, passed now.
* Only present when `baselineResultsFrom` was provided.
*/
improvements?: number;
/**
* Average tool precision across all client cases that have a
* `toolsTriggered` assertion (precision = fraction of called tools
* that were expected). Only present when at least one such case ran.
*/
datasetToolPrecision?: number;
/**
* Average tool recall across all client cases that have a
* `toolsTriggered` assertion (recall = fraction of required tools
* that were actually called). Only present when at least one such case ran.
*/
datasetToolRecall?: number;
/**
* Harmonic mean of `datasetToolPrecision` and `datasetToolRecall`.
* Only present when at least one case contributes precision/recall data.
*/
datasetToolF1?: number;
/**
* Optimization tracking metadata captured at run time.
*/
metadata?: EvalRunMetadata;
/**
* Aggregate token usage from every client case's model calls.
*/
totalClientUsage?: UsageMetrics;
/**
* Aggregate token usage of judges across all cases, from judges that report it.
*/
totalJudgeUsage?: Partial<UsageMetrics>;
}Run a tool optimization: establish a baseline, give each candidate its tool metadata (tools), compare against the baseline, rank by a metric, guard against regressions, and emit a structured improvement proposal. This is the high-level API that wraps the manual baseline → candidate → compareEvalRuns loop.
Parameters:
options: ToolOptimizationOptionsdataset: EvalDataset- The dataset to run (never mutated)variants?: ToolOverrideVariant[]- Static candidates tried in round 0proposeVariants?: (ctx: ProposeVariantsContext) => Promise<ToolOverrideVariant[]>- Callback returning the next candidates from prior-round evidence; return[]to stopmetric?: 'passRate' | 'toolF1' | 'toolPrecision' | 'toolRecall'- Ranking metric (default'passRate')maxRounds?: number- Round budget (default1)minImprovement?: number- Stop when a round's best gain over the best so far is below this (default0); withbetter: 'lower'a gain is a decreaseallowRegressions?: boolean- Allow winners that break cases (defaultfalse)regressionCheck?: 'significant' | 'any-case'- How breakage is judged on regression cases.'significant'(default): they got worse as a group (exact paired sign-flip test, p < 0.025) or one did on its own (Fisher's exact test, Holm-corrected at 0.05).'any-case': any case that passes with the baseline fails with the candidate. Either way, recommending needs a clear improvement: p < 0.025 / variants tried. See How variants are judgedregressionTag?: string- Tag marking regression cases (default'regression'). When no case has it, the baseline runs once more, only to group casesheldOutTag?: string- Tag marking held-out cases: left out of ranking and hidden fromproposeVariants(default'held-out')- Plus
runEvalDatasetpassthrough:client,model,clientOptions,defaultTrials,defaultJudgeReps,concurrency,filterTags,schemas,judgeModel
context: EvalContext-{ mcp, testInfo? }from your test
Returns: ToolOptimizationResult
baseline- The baseline run: no tool variant on a dataset; on an eval, the base variant as configured (candidates replace itstools)grouping-'declared'(fromregressionTag) or'grouping-run';groupingBaselineholds the extra run when one was neededrounds- Every round's candidates with per-candidateresult,comparison,metricValueandmetricDelta(without held-out cases),measurement(per-group pass rates, changes with p-values and verdicts, andbrokenCaseIds),improvementandfixes(the clearly-better call, adjusted for every variant tried),disqualifiedwinner- Best non-disqualified candidate across all roundsproposal-VariantImprovementProposalwithrecommendation: 'apply' | 'reject' | 'inconclusive', metric values,toolChanges, and improved/regressed case idsreason- Why the optimization stopped:'no-variants' | 'no-improvement' | 'max-rounds' | 'threshold-met'
import { runToolOptimization } from '@gleanwork/mcp-server-tester/evals';
const result = await runToolOptimization(
{
dataset,
variants: [variant],
metric: 'passRate',
defaultTrials: 10,
},
{ mcp, testInfo }
);
if (result.proposal?.recommendation === 'apply') {
console.log(result.winner?.variant.id, result.proposal.delta);
}On an eval config. Pass evalConfig instead of a dataset and context, and the candidates run as the eval config's variants, on any client that can show tool metadata: in-process for mst, through MST's tool proxy for plugin clients, claude-code and cowork. The base variant (evalConfig.baseVariant, or the eval config's baseline) is the baseline; each candidate runs as a copy of it with the candidate's tool metadata as its tools.
const result = await runToolOptimization({
evalConfig: { configPath: './eval.json', baseVariant: 'control' },
variants: [conciseSearch, verboseSearch],
metric: 'input_tokens_mean', // passRate, trialPassRate, or any numeric variant metric
better: 'lower',
});metric-passRate(default),trialPassRate, or any numeric key of a variant'smetrics. A candidate that reports no value for it is disqualified, withmetricUnavailable: true.better-'higher'(default) or'lower', for tokens, cost or time. Ranking,minImprovementand the recommendation follow it;metricDeltaandproposal.deltastay candidate minus baseline.evalConfigalso takesrootDir,pluginPaths,pluginsandsecretsFile, asrunEvaldoes. Staticvariantsrun in one run with the baseline; later rounds run as their own runs. Variant ids must be unique and differ from the base variant's name. Each run is stored like any other run of the eval config.
A candidate that breaks cases that work today is disqualified from winning unless allowRegressions: true; the best candidate tried is still surfaced in proposal with recommendation: 'reject' so an agent can see what broke. See MCP Client Simulation for the full agent-loop example.
Run a single eval case. Useful when you want fine-grained control over individual cases outside of a dataset, or when building custom eval orchestration.
Parameters:
evalCase: EvalCase- The eval case to runcontext: EvalContextmcp: MCPFixtureApi- MCP fixture APItestInfo?: TestInfo- Playwright test info (for reporter integration)expect?: Expect- Playwright expect (for snapshot support)
options?: EvalCaseOptionsplugins?: readonly Plugin[]- Plugins whose extensions the case usesdatasetName?: string- Dataset name for the result (default:'single-case')client?: string,model?: string,clientOptions?: ClientOptions- The client the case runs on, as forrunEvalDataset
Returns: Promise<EvalCaseResult>
import { runEvalCase } from '@gleanwork/mcp-server-tester';
test('single eval case', async ({ mcp }, testInfo) => {
const result = await runEvalCase(
{
id: 'search-check',
input: 'Find the planning docs',
assertions: { toolsTriggered: { calls: [{ name: 'search' }] } },
},
{ mcp, testInfo },
{ client: 'mst', model: 'claude-haiku-4-5' }
);
expect(result.pass).toBe(true);
});When evalCase.trials > 1, the case is run multiple times and result.passRate is populated with the fraction of passing trials.
Pure validation functions that power both Playwright matchers and the eval runner. Each returns a ValidationResult with pass, message, and optional details. Use these when you need validation logic outside of Playwright's expect() — for example in Vitest/Jest tests, eval datasets, or custom pipelines.
import { validateText, validateSchema } from '@gleanwork/mcp-server-tester';
interface ValidationResult {
pass: boolean;
message: string;
details?: Record<string, unknown>;
metrics?: { precision?: number; recall?: number };
}Checks that the response contains all expected text substrings.
Parameters:
response: unknown— The response to validateexpected: string | string[]— Substring(s) to findoptions?: TextValidatorOptions—{ caseSensitive?: boolean }(default:true)
const result = validateText(response, ['temperature', 'conditions']);
const result2 = validateText(response, 'hello', { caseSensitive: false });Checks that the response matches all expected regex patterns.
Each pattern check starts at index zero and leaves the supplied RegExp object's
lastIndex unchanged. You can safely reuse global and sticky patterns.
Parameters:
response: unknown— The response to validatepatterns: string | RegExp | (string | RegExp)[]— Pattern(s) to matchoptions?: PatternValidatorOptions—{ caseSensitive?: boolean }(default:true)
const result = validatePattern(response, /temperature: \d+/);
const result2 = validatePattern(response, ['\\d+ degrees', /humidity: \d+%/]);Checks that the response is (or is not) an error, optionally with a specific message.
Parameters:
response: unknown— The response to validateexpected?: boolean | string | string[]—true= expect any error,false= expect no error,string= expect error containing text (default:true)
const result = validateError(response, true); // any error
const result2 = validateError(response, false); // no error
const result3 = validateError(response, 'not found'); // error with messageChecks that the response size in bytes is within bounds.
Parameters:
response: unknown— The response to validateoptions: SizeValidatorOptions—{ minBytes?: number; maxBytes?: number }(at least one required)
const result = validateSize(response, { maxBytes: 10_000 });
const result2 = validateSize(response, { minBytes: 100, maxBytes: 50_000 });Validates the response against a Zod schema. Automatically parses JSON text responses.
Parameters:
response: unknown— The response to validateschema: ZodType— Zod schema to validate againstoptions?: SchemaValidatorOptions—{ strict?: boolean }(default:false)
import { z } from 'zod';
const WeatherSchema = z.object({
temperature: z.number(),
conditions: z.string(),
});
const result = validateSchema(response, WeatherSchema);Deep equality comparison using JSON serialization.
Parameters:
actual: unknown— The actual responseexpected: unknown— The expected response
const result = validateResponse(response, { status: 'ok', count: 42 });Validates the tool calls in a client case's trace. Only applicable to client cases.
Parameters:
response: unknown— Must be anMstClientSimulationResultassertion: ToolCallAssertion— Expected tool call specification
import type { ToolCallAssertion } from '@gleanwork/mcp-server-tester';
const assertion: ToolCallAssertion = {
calls: [{ name: 'search', required: true }],
order: 'any',
exclusive: false,
};
const result = validateToolCalls(simulationResult, assertion);
// result.metrics contains { precision, recall }Validates the number of tool calls in a client case's trace. Only applicable to client cases.
Parameters:
response: unknown— Must be anMstClientSimulationResultoptions: ToolCallCountOptions—{ min?: number; max?: number; exact?: number }
const result = validateToolCallCount(simulationResult, { min: 1, max: 3 });Evaluates a response with a judge: the built-in rubric LLM judge or a plugin judge. Returns a Promise<ValidationResult>. A judge that can't score the response (an unknown judge, invalid options, an API error, a score outside 0 to 1) fails with details.error set. A judge that skips passes with details.skipped set.
Parameters:
response: unknown— The response to evaluateconfig: JudgeValidatorConfig— Judge configurationrun?: JudgeRun—{ evalCase?, clientResponse?, evidence? }, from which the judge's{ case, trial }input is built (see Judge contract). Without it, the case is empty except forexpected.answer(thereference).
JudgeValidatorConfig:
| Field | Type | Default | Description |
|---|---|---|---|
judge |
string |
'rubric' |
The built-in rubric, or a plugin judge as <namespace>/judge/<name> |
rubric |
RubricSpec |
— | Shorthand for the rubric judge (required unless judge is set) |
reference |
unknown |
— | Reference response to compare against |
threshold |
number |
0.7 |
Minimum mean score to pass (0–1) |
reps |
number |
1 |
Times the judge scores the response, for every judge (scores averaged) |
options |
Record<string, unknown> |
— | The judge's own options; without it, a named judge gets its other flat fields |
provider |
ProviderKind |
'anthropic' |
The rubric judge's LLM provider (also model, temperature, and so on) |
const result = await validateJudge(response, {
rubric: 'Does the response accurately describe the weather?',
threshold: 0.8,
});Compares a response's text, after sanitizing, with a named snapshot in a SnapshotStore. toMatchToolSnapshot and eval snapshot assertions both use it. Throws when a sanitizer is invalid, which is a configuration error, not a mismatch.
Parameters:
response: unknown— The response to comparename: string— Snapshot nameoptions.store: SnapshotStore— Where snapshots live:playwrightSnapshotStore(expect)inside a Playwright test, or your own{ match(name, content, { negated }) }that resolves to{ pass, message }options.sanitizers?: SnapshotSanitizer[]— Applied before comparison (seetoMatchToolSnapshot)options.negated?: boolean— Compare for a.notassertion:passis still "matches", but the store must not write snapshots, and a missing snapshot counts as a match.playwrightSnapshotStoreuses Playwright's own.not.toMatchSnapshot.
import {
validateSnapshot,
playwrightSnapshotStore,
} from '@gleanwork/mcp-server-tester';
const result = await validateSnapshot(response, 'weather', {
store: playwrightSnapshotStore(expect),
sanitizers: ['uuid', 'iso-date'],
});Runs a custom predicate, which receives the response and its extracted text. description names the predicate in default messages (default 'custom predicate'). A predicate that throws fails with details.error set, so you can tell a crash from a false.
const result = await validatePredicate(
response,
(_raw, text) => text.includes('temperature'),
'mentions temperature'
);Custom Playwright matchers for writing inline assertions against MCP tool responses. Import assertions from the package or its fixtures:
import { expect } from '@gleanwork/mcp-server-tester';
// or, when using fixtures:
import { test, expect } from '@gleanwork/mcp-server-tester/fixtures/mcp';Assert that the tool response exactly deep-equals the expected value.
test('exact response', async ({ mcp }) => {
const result = await mcp.callTool('calculate', { a: 2, b: 3 });
expect(result).toMatchToolResponse({ result: 5 });
});Assert that the tool response text contains the given substring(s).
test('text contains', async ({ mcp }) => {
const result = await mcp.callTool('get_weather', { city: 'London' });
expect(result).toContainToolText('temperature');
expect(result).toContainToolText(['London', 'temperature', 'humidity']);
});Assert that the tool response text matches the given regex pattern(s).
test('pattern match', async ({ mcp }) => {
const result = await mcp.callTool('get_weather', { city: 'London' });
expect(result).toMatchToolPattern('Temperature: \\d+°[CF]');
expect(result).toMatchToolPattern(['^## Weather', '\\d{4}-\\d{2}-\\d{2}']);
});Assert that the tool response validates against a Zod schema.
import { z } from 'zod';
const WeatherSchema = z.object({
city: z.string(),
temperature: z.number(),
conditions: z.string(),
});
test('schema validation', async ({ mcp }) => {
const result = await mcp.callTool('get_weather', { city: 'London' });
expect(result).toMatchToolSchema(WeatherSchema);
});Assert that the tool response matches a saved Playwright snapshot. Use sanitizers to normalize variable fields (timestamps, UUIDs, etc.) before comparison.
test('snapshot', async ({ mcp }, testInfo) => {
const result = await mcp.callTool('help', {});
expect(result).toMatchToolSnapshot('help-output');
});
// With sanitizers
expect(result).toMatchToolSnapshot('user-profile', ['uuid', 'iso-date']);Assert that the tool response is an error (or is not an error when negated). Optionally assert on the error message.
test('error handling', async ({ mcp }) => {
const result = await mcp.callTool('nonexistent_tool', {});
expect(result).toBeToolError();
// Assert specific error message substring
expect(result).toBeToolError('not found');
// Assert response is NOT an error
const good = await mcp.callTool('get_weather', { city: 'London' });
expect(good).not.toBeToolError();
});Assert that the tool response passes a judge: the built-in rubric judge (an LLM call) or, with { judge: '<namespace>/judge/<name>' }, a plugin judge. The matcher takes passingThreshold (default 0.7), reference, reps, provider, model, judge and options.
test('semantic quality', async ({ mcp }) => {
const result = await mcp.callTool('search_docs', { query: 'authentication' });
await expect(result).toPassToolJudge(
{
text: 'The results should be relevant to the query about authentication. Score 0-1.',
},
{ passingThreshold: 0.7 }
);
});Assert that the tool response size is within specified byte bounds.
test('response size', async ({ mcp }) => {
const result = await mcp.callTool('list_files', {});
expect(result).toHaveToolResponseSize({ minBytes: 10, maxBytes: 50000 });
});Assert that the tool response satisfies a custom predicate function.
test('custom predicate', async ({ mcp }) => {
const result = await mcp.callTool('list_files', {});
expect(result).toSatisfyToolPredicate(
(r) => Array.isArray(r.content) && r.content.length > 0,
'response should contain at least one file'
);
});Assert that the client made specific tool calls when given a natural language prompt. Only meaningful for client cases.
test('tool discovery', async ({ mcp }) => {
const result = await mcp.callTool('search', { query: 'find recent docs' });
expect(result).toHaveToolCalls({
calls: [{ name: 'search', required: true }],
order: 'any',
exclusive: false,
});
});Assert that the client made a specific number of tool calls. Only meaningful for client cases.
test('call count', async ({ mcp }) => {
const result = await mcp.callTool('search', { query: 'find docs' });
expect(result).toHaveToolCallCount({ min: 1, max: 5 });
});Extract text content from various MCP response formats.
Parameters:
response: CallToolResult- MCP tool call result
Returns: string
const result = await mcp.callTool('get_info', {});
const text = extractText(result);Normalize whitespace for consistent comparison.
Parameters:
text: string- Text to normalize
Returns: string
const normalized = normalizeWhitespace(' hello\n\n world ');
// Returns: "hello world"Create an LLM judge for semantic evaluation of tool responses.
Parameters:
config?: JudgeConfig(all fields optional)-
provider?: 'anthropic' | 'vertex-anthropic' | 'anthropic-agent-sdk' | 'openai' | 'google'- LLM provider (default:'anthropic'). Each loads its SDK on first use; install the one you use:Provider Package anthropic@anthropic-ai/sdkvertex-anthropic@anthropic-ai/vertex-sdkanthropic-agent-sdk@anthropic-ai/claude-agent-sdkopenaiopenaigoogle@google/generative-ai -
model?: string- Model name (default:'claude-sonnet-4-6') -
temperature?: number- Temperature 0–1 (default:0.0) -
maxTokens?: number- Maximum tokens for response (default:4096) -
maxBudgetUsd?: number- Maximum budget in USD (default:0.10) -
effort?: string- Reasoning effort, such as'high'or'xhigh'. OpenAI only: the judge calls the Responses API with this effort,maxTokensas its output budget (reasoning included) and no temperature, which reasoning models reject -
maxToolOutputSize?: number- Fail if response exceeds this byte count
-
Returns: Judge
Default (Claude):
import { createJudge } from '@gleanwork/mcp-server-tester';
const judge = createJudge();
// Requires: ANTHROPIC_API_KEY environment variable, or a gateway credential
// (see LLM Gateways: ./llm-gateways.md)With configuration:
const judge = createJudge({
provider: 'openai',
model: 'gpt-4o',
temperature: 0.0,
});
// Requires: OPENAI_API_KEY environment variable, or a gateway credentialThe provider call createJudge is built on, without its prompt or parser: a plugin judge that brings its own prompt and reads its own answer uses it to reach models the way MST's judges do, with the same providers, credentials and LLM gateway settings. It takes the same JudgeConfig and throws, as createJudge does, for an unknown provider or a missing credential. preflightJudge(config) checks the same without calling a model, for a plugin judge's preflight.
import { createJudgeCompletion } from '@gleanwork/mcp-server-tester';
const complete = createJudgeCompletion({
provider: 'openai',
model: 'gpt-5.6-luna',
effort: 'xhigh',
maxTokens: 64000,
});
const { text, usage, truncated } = await complete({
system: 'Answer in JSON.',
prompt: 'Rate each citation.',
});The following utilities are available for checking whether optional LLM provider packages are installed. They are useful for debugging provider configuration issues but are not part of the typical test-writing path.
Check whether the npm package required for a given mst client provider is installed in the current environment.
import { isProviderAvailable } from '@gleanwork/mcp-server-tester/evals';
if (!isProviderAvailable('anthropic')) {
console.warn('Install @anthropic-ai/sdk to use the anthropic provider');
}Return a human-readable message describing the missing dependency for a provider, suitable for displaying in error output or test skip conditions.
import { getMissingDependencyMessage } from '@gleanwork/mcp-server-tester/evals';
const message = getMissingDependencyMessage('openai');
// e.g. "Provider 'openai' requires the 'openai' package. Run: npm install openai"See LLM Client Guide for full details on configuring the mst client.
Run MCP protocol conformance checks.
Parameters:
mcp: MCPFixtureApi- MCP fixture APIoptions?: objectrequiredTools?: string[]- Tools that must be presentvalidateSchemas?: boolean- Validate tool input schemas (default:true)checkServerInfo?,checkResources?,checkPrompts?: boolean- Toggle those checks (default:true)probe?: boolean- Allow raw probe requests for 2026-07-28 rejection rules (default:true)skills?: { maxSkills?: number; verifyFiles?: 'skill-md' | 'all'; maxPages?: number } | false- Tune or disable the skills checks (defaults: 25 skills,'skill-md', 64 pages)
testInfo?: TestInfo- Attach results to the MCP reporter
Checks are selected by the negotiated era: legacy connections run the core checks; 2026-07-28 connections also run the modern checks; servers that declare the skills extension get the skills checks in every era.
Returns: Promise<MCPConformanceResult>
const result = await runConformanceChecks(mcp, {
requiredTools: ['get_weather', 'search_docs'],
validateSchemas: true,
});
expect(result.pass).toBe(true);Result Structure:
interface MCPConformanceResult {
pass: boolean; // every non-skipped 'must' check passed
checks: Array<{
name: string;
pass: boolean;
message: string;
severity?: 'must' | 'should'; // failing 'should' checks are warnings
skipped?: boolean; // not applicable here; message says why
specVersion?: string; // e.g. '2026-07-28'
specRef?: string; // spec section the check enforces
}>;
protocol: MCPProtocolInfo; // { requested, negotiated, era }
raw: MCPConformanceRaw; // serverInfo, capabilities, tools, resources, prompts
}Connect to the same server once per protocol and check it serves the same tools, tool definitions, resources, prompts, skills, and capabilities in each, and that protocol: 'auto' picks the modern era.
Parameters:
config: MCPConfigoptions?: { protocols?: ProtocolSetting[]; checkAuto?: boolean; clientOptions?: Omit<CreateMCPClientOptions, 'protocol'> }-protocolsdefaults to['legacy', '2026-07-28']testInfo?: TestInfo
Returns: Promise<MCPCrossEraResult> with pass, checks, and connections.
Expand one Playwright project into one project per protocol, named <name>@<protocol>, with mcpProtocol and mcpConfig.protocol set.
projects: [
...protocolMatrix({ name: 'docs', use: { mcpConfig } }, [
'legacy',
'2026-07-28',
]),
];{ requested, negotiated, era } for a client created by MST.
Classify a dated revision as 'legacy' or 'modern', and check a string is a YYYY-MM-DD revision.
DEFAULT_PROTOCOL_SETTING is 'legacy', the protocol used when none is set. ProtocolMatrixEntry<T> is the type of each project protocolMatrix() returns.
The dated revisions MST speaks, in each era, and the first modern one (2026-07-28). Pass them to protocolMatrix() to run a project across every revision:
import {
LEGACY_PROTOCOL_VERSIONS,
MODERN_PROTOCOL_VERSIONS,
protocolMatrix,
} from '@gleanwork/mcp-server-tester';
const projects = protocolMatrix(project, [
...LEGACY_PROTOCOL_VERSIONS,
...MODERN_PROTOCOL_VERSIONS,
]);What mcp.callTool() uses: calls a tool on a raw SDK Client and turns a JSON-RPC error sent by the server (such as -32602 for an unknown tool) into an isError: true result whose text is MCP error <code>: <message>. Local SDK errors (timeouts, closed connections, auth) still reject.
The { code, message, data? } of the protocol error a result was made from, or null for results the server returned.
mcp.skills wraps these; use them directly with a raw SDK Client. See Agent Skills.
| Export | Purpose |
|---|---|
getSkillsExtension(client) |
The server's io.modelcontextprotocol/skills settings, or null when it doesn't declare the extension |
SKILLS_EXTENSION_ID |
'io.modelcontextprotocol/skills', the extension's capability key |
listSkills(client, { maxPages? }) |
All skills/list entries, following nextCursor (default 64 pages) |
getSkill(client, uri) |
One entry from skills/get |
readSkillFile(client, uri) |
Read a skill file with resources/read: { uri, text?, bytes, mimeType? } |
verifySkillFile(entry, file) |
Problems (digest, size, SKILL.md frontmatter) found checking a read file against its entry |
validateSkillEntry(entry) |
SEP-2640 entry problems, each with severity: 'must' | 'should' |
parseSkillFrontmatter(markdown) |
The YAML frontmatter of a SKILL.md as an object, or null without one |
SkillEntrySchema |
Zod schema for the wire shape of an entry (use validateSkillEntry() for the SEP rules) |
/**
* A case's assertions
*
* Mirrors the Playwright matcher API for consistency.
*/
export interface EvalAssertions {
/**
* Text substring(s) the client's answer must contain (toContainToolText)
*/
containsText?: string | string[];
/**
* Regex pattern(s) the client's answer must match (toMatchToolPattern)
*/
matchesPattern?: string | string[];
/**
* Asserts which tools the client called. Needs structured tool evidence
* (a client that reports what it called).
*/
toolsTriggered?: {
/** Expected tool calls */
calls: Array<{
/** Tool or explicitly selected client event name. */
name: string;
kind?: TraceEvent['kind'];
source?: TraceEvent['source'];
server?: string;
/** Expected arguments (partial match — extra keys are allowed) */
arguments?: Record<string, unknown>;
/** Whether this call MUST have been made (default: true) */
required?: boolean;
}>;
/**
* 'strict': calls must appear in the exact order listed
* 'any': calls can appear in any order (default)
*/
order?: 'strict' | 'any';
/** If true, no tool calls outside the `calls` list are allowed */
exclusive?: boolean;
};
/**
* Asserts the number of tool calls the client made. Needs structured tool
* evidence.
*/
toolCallCount?: {
/** Minimum number of tool calls */
min?: number;
/** Maximum number of tool calls */
max?: number;
/** Exact number of tool calls */
exact?: number;
};
}A case's judges entries, written like an eval config's judges.
/**
* One of a case's judges, written like an eval config's: a reference
* (`acme/judge/completeness`) or `{ "type": <reference>, ...options }`.
*/
export type CaseJudge = string | CaseJudgeConfig;
/** How a judge grades: the settings a case judge and a judge request share. */
interface JudgeSettings {
/** The judge's options, validated by its schema. */
options?: Record<string, unknown>;
/** Other flat fields are the judge's options too. */
[key: string]: unknown;
/** The rubric judge's rubric: a built-in name or a custom `{ text }`. */
rubric?: BuiltInRubric | { text: string };
/** Reference answer to compare against. Default: the case's `expected.answer`. */
reference?: unknown;
/** Score threshold for passing (0-1, default: 0.7) */
threshold?: number;
/** Number of judge evaluations. Overrides EvalCase.judgeReps. */
reps?: number;
/** The rubric judge's provider. @default 'anthropic' */
provider?: ProviderKind;
/** Model override (e.g., 'claude-opus-4-20250514') */
model?: string;
/** Environment variable name for API key */
apiKeyEnvVar?: string;
/** Max tokens for judge response */
maxTokens?: number;
/** Temperature for judge LLM (0-1) */
temperature?: number;
/** Max budget in USD per evaluation */
maxBudgetUsd?: number;
/** Fail if response exceeds this size in bytes before judging */
maxToolOutputSize?: number;
}
/**
* A case judge with its settings: `type` names the judge (the built-in
* `rubric`, or `<namespace>/judge/<name>` from a plugin); `threshold`,
* `reference` and `reps` say how it grades; other flat fields (or
* `options`) are the judge's own options.
*/
export interface CaseJudgeConfig extends JudgeSettings {
type: string;
}/**
* A single eval case: an input the client under test acts on, and what to
* assert about what it did. The case runs on the `client`, `model` and
* `clientOptions` it inherits from the eval (or the run), changed by its own.
*
* Direct tool calls aren't cases: write them as Playwright tests with
* `mcp.callTool()` and the matchers.
*/
export interface EvalCase extends ClientFields {
/**
* Unique identifier for this test case
*/
id: string;
/**
* Human-readable description of what this test case validates
*/
description?: string;
/**
* The user's request the client acts on, sent as its prompt.
*
* @example "Get the weather for London and tell me if I need an umbrella"
*/
input: string;
/** Additional metadata for this test case. */
metadata?: Record<string, unknown>;
/**
* Number of trials (independent runs) of this case. When > 1,
* `EvalCaseResult.passRate` is the share of trials that passed, and
* `pass` is decided by `passThreshold`.
* @default 1
*/
trials?: number;
/**
* Share of trials (0–1) that must pass for the case to pass.
* @default 1.0 (every trial)
*/
passThreshold?: number;
/**
* Number of times to run each of the case's judges. Scores are averaged;
* the mean must meet the threshold to pass. Reduces judge variance caused
* by non-determinism. A judge's own `reps` overrides this value.
* @default 1
*/
judgeReps?: number;
/**
* What the case expects, for graders: `answer` (the reference answer,
* passed to judges as `reference` unless a judge sets its own),
* `criteria` (rubric criteria keyed by name), and any other ground truth.
* Judges read it as `case.expected`.
*/
expected?: {
answer?: unknown;
criteria?: Record<string, string>;
[key: string]: unknown;
};
/**
* Arbitrary string labels for this case.
* Use for filtering eval runs with `EvalRunnerOptions.filterTags`
* and for slicing results by category.
*
* @example ['tool-finding', 'multi-hop', 'search']
*/
tags?: string[];
/**
* Assertions (code graders) each trial must pass. All of them run.
*
* @example
* ```json
* {
* "id": "weather-london",
* "toolName": "get_weather",
* "args": { "city": "London" },
* "assertions": {
* "containsText": ["temperature", "conditions"],
* "schema": "WeatherResponse",
* "responseSize": { "maxBytes": 10000 },
* "isError": false
* }
* }
* ```
*/
assertions?: EvalAssertions;
/**
* Judges (model graders) that score each trial. Each must pass. They run
* on top of the eval config's `judges`; when the case and the eval config
* list the same judge, the case's settings win.
*
* @example
* ```json
* "judges": [
* { "type": "rubric", "rubric": "correctness", "threshold": 0.8 },
* "acme/judge/completeness"
* ]
* ```
*/
judges?: CaseJudge[];
}- See the Authentication Guide for OAuth and token auth
- See the Assertions Guide for detailed assertion usage
- Check out the Quick Start Guide for getting started
- Explore Examples for real-world usage patterns