- Inspector Evaluate UI — author suites and cases, run them hosted, inspect traces. See Evaluate for the product view.
@mcpjam/sdk— run evals in your own process (EvalTest/EvalSuite) and upload results. See Running Evals.mcpjamCLI — author, start, and inspect hosted runs from a terminal (mcpjam cloud eval …). See the command reference.
The big picture
Two distinct write paths matter:- Ingestion — externally produced results (SDK auto-save, manual reporting APIs, artifact uploads) enter through the
eval-ingestHTTP surface. - Hosted runs — the Inspector’s own runner executes iterations server-side and persists through its recorder; the CLI’s
eval runstarts these hosted runs via the platform API.
HostConfig consolidation
The Playground (live chat) and Evals share a single, portablehostConfig core that lives in @mcpjam/sdk/host-config/. Everything that drives an LLM-plus-MCP loop — host style, model, sandbox/permission policy, OpenAI-Apps compat, tool-visibility, MCP server selection — flows through one canonical shape and one hash.
@mcpjam/sdk/host-config/internal; see mcpjam-backend/convex/lib/hostConfigV2.ts — the file is a thin Convex-bound wrapper (server-scope validation, Id<'servers'> boundary cast) over the SDK functions, not a parallel implementation.
Two boundaries inside the inspector consume this module:
- Outbound (backend → target MCP server). The eval runner builds an
MCPClientManager, connects to the suite’s servers, and drives anHostRunnerover the resolved host. Imports:extractHostExecutionPolicy/buildHostIterationMetadatafrom@mcpjam/sdk/host-config/internal(Stage 3) — was previouslyserver/services/evals/host-execution-policy.ts; the local file is now deleted.resolveOpenAiCompatForHostConfigfrom@mcpjam/sdk/host-config/internal(Stage 3) — was previously the localcompat-runtime.ts; only the Convex-boundloadSuiteHostConfigremains in the inspector.filterAppOnlyToolsfrom@mcpjam/sdk/host-config/internal(Stage 3) — same predicate the chat-v2 path uses, no fork.
- Inbound (frontend → backend). The eval results API persists per-iteration
hostConfigIdrows that reference the samehostConfigstable the scenario/playground writes.
HostRunner, HostRuntime, HostExecutor (Stage 4 rename)
Inside @mcpjam/sdk, the executor surface was renamed in 1.11 to align with MCP spec vocabulary:
TestAgent→HostRunner— synchronous executor over a host snapshot with tools pre-resolved.EvalAgent→HostExecutor— interface implemented by bothHostRunnerandHostRuntime..prompt()→.run()on the interface and both impls.Host.addServer()→Host.requireServer().HostRuntime(new) — live binding of aHostto anMCPClientManager(host.withManager(manager, { apiKey })). Snapshots the live host on every.run(); stateless across turns.EvalTest.run/EvalSuite.runparameter renamedagent→executor.
EvalTest and EvalSuite accept any HostExecutor, so an external user can run the same eval against their own runtime as long as it implements getHostSnapshot?.().
Per-iteration host metadata + Stage 5 wire-send
HostRunner snapshots the host once at construction; HostRuntime re-snapshots on every .run(), so mid-eval mutation to the bound Host is captured per-iteration. Each IterationResult carries hostSnapshot?: HostJson and a derived metadata stamp (buildHostSnapshotMetadata) that is additively merged into EvalResultInput.metadata (existing keys are never overwritten — conflicting host keys are namespaced under host.<key>).
The SDK eval reporter additionally sends { hostConfig, hostConfigHash } at the run boundary. The v1 ingest surface always accepts the pair — the old per-baseUrl capability probe is gone:
POST /api/v1/projects/:projectId/eval-ingest/runs/start(chunked path) — body includes the pair when iteration snapshots are homogeneous.POST /api/v1/projects/:projectId/eval-ingest/report(one-shot path) — same.POST /api/v1/projects/:projectId/eval-ingest/runs/iterationsand…/runs/finalize— never carry the pair (per-run, not per-batch).
iteration.hostSnapshot → executor.getHostSnapshot?.() → MCPJamReportingConfig.host. Today EvalResultInput carries no hostSnapshot field, so the shipped reporter resolves executor → explicit host. The iteration slot and its homogeneity gate (send only when all snapshots canonicalize to the same hash) are reserved for when per-iteration capture reaches the wire shape; see the comment in resolveWireHostConfigForRun (sdk/src/report-eval-results.ts). Errors thrown while resolving or canonicalizing the snapshot log a warning and omit the pair rather than failing the upload. Two cases drop it silently by design: heterogeneous iteration snapshots, and an explicit host whose toJSON() fails (see resolveRunLevelHostSnapshot in sdk/src/sdk-evals-host-config-source.ts).
hostConfigHash is a transport-integrity check — the backend re-runs normalizeSdkEvalHostConfigForWire → canonicalizeHostConfigV2 → computeHostConfigHashV2 and rejects on mismatch. It is not the persisted storage id; suite-resolved Convex Id<'servers'> are layered on top at storage time, so the persisted hostConfigsV2 row’s hash differs from the wire hash by design.
Two distinct sandbox layers
- The persisted host-config sandbox shape (
mcpProfile.apps.sandbox.csp+.permissions) is allowlist-only — there is nodenyfield. This is the shape the backend stores and the canonicalizer hashes. - The SDK runtime CSP resolver (
sandbox-policy.ts) carriesdenyplus a hosted clamp for render-time enforcement. It is NOT the same type; the two share only leaf subtypes (SandboxCspMode,SandboxPermissionsMode, four-directive domain-set).
deny key. Don’t reuse SandboxCspPolicy / SandboxPermissionsPolicy for persisted host configs.
Result ingestion API (eval-ingest)
External results enter through five POST endpoints, implemented as a thin proxy in mcpjam-inspector/server/routes/v1/eval-ingest.ts over the Convex /v1/evals/ingest/* surface:
Proxy behavior worth knowing when debugging:
- The caller authenticates like any
/api/v1route (typically ansk_API key); the proxy forwards to Convex with a delegated org-scoped JWT, not the caller’s credential. - A literal
defaultin the:projectIdsegment is stripped from the payload so the backend resolves the org’s Default project; any other value overwritespayload.projectId. - The proxy caps request bodies at 6 MiB (the backend enforces its own 5 MiB limit) and times out after 60 s; status and body pass through verbatim.
SDK reporter
sdk/src/report-eval-results.ts implements the client side of ingestion (types in sdk/src/eval-reporting-types.ts; API reference in Saving Eval Results):
- Auth —
Authorization: Bearer <sk_ key>, resolved frominput.apiKey ?? MCPJAM_API_KEY. Base URL frominput.baseUrl ?? MCPJAM_BASE_URL(defaulthttps://app.mcpjam.com); project frominput.project ?? MCPJAM_PROJECT_ID(defaultdefault). - Flow — widget snapshots upload first (via
artifacts/upload-url); then, if the run fits in one request (≤ 200 results and ≤ 1 MiB), a singlereportcall; otherwiseruns/start→ chunkedruns/iterations→runs/finalize. - Retry — 429/5xx, network errors, and timeouts retry with base delays of 250/750/1750 ms plus ±20% jitter. Billing-limit errors never retry. If
runs/startreports the run already completed (idempotent reuse), the reporter short-circuits.
Predicate gate
The deterministic pass/fail layer lives in@mcpjam/sdk/predicates (sdk/src/predicates/), browser-safe so the Inspector GUI runner and the mcpjam cloud eval CLI share one implementation:
types.ts— thePredicateunion (12 types) plus Zod schemas. The union grows only when a real corpus task demands it.evaluate.ts—evaluatePredicate/evaluatePredicates/evaluateTurnChecks, pure functions over anIterationTranscript.transcript.ts— builds transcripts from traces (buildIterationTranscript,buildTurnTranscript).argMatcher.ts— reuses the same argument engine asexpectedToolCallsmatching, so both agree on what “args match” means.
- Convex mirror.
mcpjam-backend/convex/lib/predicates.tshand-mirrors the union (Convex cannot import@mcpjam/sdk). Parity is proven by the JSON fixtures insdk/tests/fixtures/predicates-parity-fixtures.jsonand their backend sibling — adding a predicate type means editing both validators and the fixtures in the same PR. - Turn scoping. Every predicate kind is turn-scopable except
tokenBudgetUnder(per-turn token usage is not reliably captured); the list isTURN_SCOPABLE_PREDICATE_KINDS, also mirrored in the backend. - Verdict rows. The runner persists one
{ predicate, passed, reason, scope? }row per predicate undertestIteration.metadata.predicates.
inherit / replace / extend overrides) is centralized in mcpjam-inspector/shared/eval-matching.ts (resolveCaseSuccessPredicates).
Tool-policy enforcement
Eval tool safety is enforced at execution time, not by hiding tools from the model. A denied tool remains visible-but-blocked: hiding it would make the model’s non-selection look like aselection-stage miss caused by the model or
server, instead of a decision made by our policy gate. A blocked call is
recorded as a policy block, does not call the MCP server, does not create a
tool trace span, and is not an eval failure. Applicable stage rows become
notMeasured with reason blockedByPolicy.
The shared contract applies this precedence:
- An explicit
denyentry blocks the tool. - An explicit
allowentry allows the tool. destructiveHint: trueis blocked by default.- In
readOnlymode, only an explicitly read-only classification is allowed. defaultmode allows the remaining tools.
readOnlyHint === true and destructiveHint !== true.
Destructive and contradictory or malformed annotations are not treated as
safe. Explicit name-based denies also apply to MCPJam internal tools; mode
derived rules apply only to MCP-server tools.
allow in mode: "default" is an override, not an allowlist: it does not
restrict the set of tools available to the model. In readOnly mode, the
mode-derived rule does not restrain the sandbox bash tool or skills because
those internal tools do not carry _serverId. Runs selecting readOnly with a
sandbox-enabled suite emit a launch warning for this limitation.
Harness evals run their MCP calls out of process, so they never reach the
in-process execution gate. They are enforced instead at the MCP proxy every
generated .mcp.json entry points at. At launch the runner resolves the policy
for every known tool of every selected server with the same shared contract, and
seals that decision table together with the proxy credential into the
X-MCPJam-Proxy-Token value the sandbox receives; the proxy opens it and blocks
a denied tools/call before the upstream call happens. Because the policy
encloses the credential, removing the policy from .mcp.json also removes
access. A tool that appears only after launch is denied (unknownAtLaunch).
tools/list is not filtered, and a block is returned as a successful MCP
result, so it is accounted exactly like an in-process block: notMeasured with
reason blockedByPolicy, never a failure attributed to the server. A policied
harness run is still refused at launch on a deployment that cannot seal
(COMPUTERS_TERMINAL_TOKEN_SECRET absent or too weak), rather than running
unenforced.
Hosted platform-authored suites currently lack the backend field required to
carry this policy. The hosted CLI therefore continues to refuse authored
defaults.toolPolicy rather than running without enforcement.
Run verdict policy (contract only)
sdk/src/contract/verdict-policy.ts pins how a completed run becomes a verdict
under verdictPolicyVersion: 2. It is a contract with no producer yet —
nothing in the SDK, the hosted runner, or the backend emits an
EvalVerdictDecision. Anything that reads one must check the version first: a
row with no verdictPolicyVersion is a legacy percent-threshold row and must
never be reinterpreted under these semantics.
The rules the contract enforces, all of them checked by zod refinements rather
than left to the producer:
- Validity is decided before the task verdict. A run that cannot be
believed is
inconclusive, neverfailed— the two mean different things to whoever is paged. Validity has three independent checks: coverage,minCompletionRate(default 0.8), andmaxEvaluatorErrorRate(default 0.1). - Coverage. Omitting
minEligibleTrialsdoes not mean “no minimum”: it selects the default floor — every configured trial attempted, and at least one gradeable trial. An explicitminEligibleTrials: Nreplaces that floor witheligibleTrials >= N, deliberately tolerating unattempted trials. - Every rate carries its own arithmetic — numerator, denominator, and the
exclusions that shrank the denominator. A zero denominator is
state: "notMeasured"with anullvalue, never a silent pass: an unsatisfiable bound fails from both directions. - A measured case passes at
passRate >= effectivePassThreshold(equality passes), and a case with zero eligible trials isinconclusiveeven at threshold 0. If validity holds, every measured case must meet its own threshold for the run to pass. - Lifecycle status and task verdict are orthogonal. A trial that ran to
completedwith a failing task verdict is normal, graded failure; a lifecycle-failedtrial has no verdict to grade and is excluded asexecutionFailed. There is no status-to-verdict mapper, on purpose.
.ts twin are generated by
npm run generate:eval-verdict-policy-schema -w @mcpjam/sdk (checked by
check:eval-verdict-policy-schema and by a test), and
sdk/tests/fixtures/eval-verdict-policy-parity-fixtures.json is the canonical
corpus to be copied verbatim into the backend when the Convex mirror lands —
including an aggregation cohort that pins the expected output a future producer
must reproduce.
Where the code lives
Earlier revisions of this page documented the pre-consolidation architecture —
/api/mcp/evals/run routes, the evals-cli/ execution engine, and backend test generation via meta-llama. Those subsystems are retired; consult git history if you need them.Resources
- MCP Specification: https://spec.modelcontextprotocol.io
- Vercel AI SDK: https://sdk.vercel.ai
- Convex Database: https://convex.dev
Questions?
If you have questions or need help contributing:- Check the GitHub Issues
- Join our Discord community
- Read the main Contributing Guide

