GuidesMCP server

MCP server

Neens exposes a Model Context Protocol (MCP) server so any MCP-compatible agent — Claude Desktop, an IDE assistant, or your own automation — can both read and write the failure→fix loop with tools instead of hand-written HTTP calls. This is the foundation of the “agents that fix agents” workflow: an agent can inspect what’s failing, curate a dataset, record a ground-truth label, stand up a judge, and move a remediation forward — all through the same permission and tenancy model your team uses.

At a glance

EndpointPOST <base-url>/mcp — MCP Streamable HTTP (JSON-RPC 2.0), stateless
Server nameneens — the name the server reports in initialize, and the name to register it under in your client (tools then appear as mcp__neens__<tool>)
AuthAuthorization: Bearer nk_live_… — your agent API key
ScopeEverything is scoped to the key’s agent; deletes are never exposed
Tools52 — 34 read + 18 write, listed below

The MCP server reuses the existing Neens REST endpoints in-process, so every tool re-runs the same authorization and tenancy checks as a normal API request. A tool can only do what the credential you connect with could already do.

Connect

Using Claude Code? Prefer the per-user browser sign-in over a shared key: run claude mcp add --transport http neens <your-neens-url>/mcp with no header, sign in through your browser, and every action is attributable to you and governed by your role. See Connect Claude Code (MCP). The agent-key method below stays the right choice for headless automation with no human to sign in.

The server speaks MCP over Streamable HTTP at POST <base-url>/mcp on the same origin as the app. Every client needs the same two things: the endpoint URL and your agent key sent as a bearer token (Authorization: Bearer nk_live_…). Pick your client below.

Your agent API key is shown in Settings → API keys (and is printed by the seed scripts for local demos). It resolves to exactly one company + agent, so the agent can only ever see and change that agent’s data.

Add the server with the Claude Code CLI, using the HTTP transport and an Authorization header:

claude mcp add --transport http neens \
  https://YOUR_NEENS_HOST/mcp \
  --header "Authorization: Bearer nk_live_your_project_key"

This registers a neens MCP server for your current agent. Start a new session and the tools become available, namespaced by the server name you registered — mcp__neens__list_traces, mcp__neens__get_fix_bundle, and so on.

Verify the connection. Once connected, ask the client to list the Neens tools (an MCP tools/list call). You should see 52 tools — 34 read and 18 write — described in What an agent can do. Seeing none means the connection isn’t working — re-check the URL and that the Authorization header is being sent.

The key is agent-scoped: it can read and perform the documented writes within its one agent, and nothing else — deletes are never exposed. The note below spells out exactly what an agent key may write.

That scope covers by-id calls, not just listings. get_trace (and every other tool that takes an id) refuses an object belonging to a different agent — even a sibling agent of the same company, and even when the id is exact. The tool reports an error rather than returning data:

// tools/call get_trace {"trace_id": "<a session in another project>"}
{
  "error": "This credential is not permitted to perform this action (project keys can read + write within their project, but cannot delete; some actions require a member/admin role).",
  "status": 403
}

Tools whose lookup filters the agent directly report 404 instead, so an out-of-scope id looks the same as one that does not exist. See API keys for the HTTP-level equivalent.

⚠️

The key’s role governs writes, exactly as in the UI. An agent key can read and create / update within its agent, but cannot delete — deleting judges, datasets, or remediations still requires an admin over the regular API. Creating datasets requires the Datasets feature (Silver tier and above).

What an agent can do

The 52 tools are grouped below by the job they do, in roughly the order a loop uses them. Every group is labelled Read or Write per tool: a read tool carries MCP’s readOnlyHint annotation so a host can auto-approve it, a write tool doesn’t. No tool deletes anything.

GroupTools
Your connection1
Traces and sessions2
Failure modes and clusters4
Scores, judges and eval runs8
Datasets, versions and exports9
Annotations1
Pre-prod evaluations5
Comparing results4
The remediation backlog4
The fix loop6
Orchestrated fix runs2
Prompt optimization2
Model sweeps3

Your connection

ToolKindDoes
get_current_projectReadReports which agent this connection is scoped to — projectName, projectId, orgName, companyName — plus credentialKind, role, and scopePinned (whether the agent is fixed for the connection or picked per request). Returns projectId: null with a note when no single agent is pinned

An agent API key and an SSO/OAuth MCP token each pin one agent, so get_current_project is the direct answer to “which agent am I connected to?”. Call it at the start of a session — for example right after signing in through SSO — to confirm you landed in the agent you expected before reading or writing anything:

// tools/call get_current_project {}
{
  "projectName": "Help agent",
  "projectId": "project_7c2a…",
  "orgName": "Support",
  "companyName": "Acme",
  "credentialKind": "user",   // "project" for a project API key; "user" for an SSO/OAuth token
  "role": "member",
  "scopePinned": true          // the agent is fixed for this connection
}

The server also names the scoped agent in its initialize reply’s instructions, so a capable client knows the agent from the handshake without an extra call. The tool never reveals another agent: the answer comes from your own credential, and a null result simply means no single agent is pinned (a user session that selects its active agent per request).

Traces and sessions

ToolKindDoes
list_tracesReadSearches the agent’s traces. Filters: cluster_id, status, agent_name, score_metric + score_status + score_label, issue_mode, failure_set, model, tool_name, tags, source, q, started_after / started_before, conversation_id, min_duration_ms / max_duration_ms, min_tokens / max_tokens. Returns the total match count and up to limit (1–50, default 20) summary rows
get_traceReadOne trace by trace_id: agent, status, timing, cost, error message, and a summary of its spans and tool calls

Use cluster_id, not the cluster’s name. model, tool_name and tags take arrays and match any of the values given; everything else is a single value. total is how many traces matched, traces holds at most limit of them.

Failure modes and clusters

ToolKindDoes
list_failure_modesReadThe latest clustering run: readiness, plus the top failure modes with session counts, NEW flags and root-cause hypotheses
get_failure_modeReadOne cluster by cluster_id: label, description, root cause, remediation summary and member session ids
list_clustersReadThe active clusters as a flat list — id, label, description, session count, L1 bucket. The lightweight way to get the cluster_id that list_traces takes
get_cluster_exemplarsReadThe members nearest a cluster’s centroid, with their distance — the shortest read that shows what a failure mode actually looks like

Business KPIs

ToolKindDoes
get_business_kpisReadThe agent’s Business KPIs — each commitment’s current value, unit, whether the target is met/missed/unknown, and its recent trend. Pass a kpi_id to drill into one KPI, which also returns the failure clusters eroding it. A null value and an unknown status are passed through verbatim — never a fabricated 0

Scores, judges and eval runs

ToolKindDoes
list_scoresReadThe scorers active in the agent and how each trends — metric key, source, target type, average, pass rate, period-over-period delta. Optional range: 24h, 7d (default), 30d, all
list_score_rowsReadIndividual score rows with the judge’s score, label and reason. Filter by metric_key, status (pass/fail), target_id / target_type, source, a specific eval run_id, min_score / max_score, lifecycle, sort and range. This is how you find which traces a judge failed. run_id reads that run’s own snapshot, which applies no metric/source/time filter — so it cannot be combined with metric_key, source or range, and asking for both is refused rather than answered with rows that only look filtered
list_judgesReadThe agent’s judges with every id the write tools need: currentVersionId, latestVersionId and a versions[] list of {id, versionNum} (newest first, up to 5), plus deployments[] where each entry carries the id that run_eval and create_preprod_run need alongside its versionId, trigger, status and last run
get_judge_runsReadOne judge’s eval runs, newest first: status, targets scored, average score and pass rate per run, plus an active/historic summary
get_eval_runReadOne eval run by id: status, per-status task counts, success rate, and a page of its scored tasks (target id, score, label, reason, error). taskTotal is the run’s whole size; tasks is at most limit rows from offset
create_judgeWriteAn LLM-prompt judge plus its version 1 (experimental), from a prompt_template that interpolates {target}, optional criteria and required_data
deploy_judgeWriteDeploys a judge version so it can run. Only judge_id is required — omit version_id for the judge’s latest version, or pass a version id or a version number like "2". trigger_policy: "manual" scores on demand, "on_new_trace" scores every new trace. See Deploying without a version id
run_evalWriteStarts an evaluation run and returns the run id, status, resolvedDeploymentId and total. Pass a deployment_id, or just judge_id to use that judge’s single enabled deployment. Optional per-run overrides: sample_size caps the run at the N most-recent eligible traces (leaving the deployment’s sampling filter untouched); success_threshold overrides the pass cutoff. See Running an eval from a judge id

One read is enough to deploy and run

list_judges is the only call you need before either write tool. Each judge it returns carries the version ids and the deployment ids, so nothing has to be looked up in the UI and nothing has to be guessed:

{
  "id": "judge_a41f",
  "name": "Grounding check",
  "currentVersionId": "jver_9c02",
  "latestVersionId": "jver_9c02",
  "latestVersionNum": 2,
  "versions": [
    {"id": "jver_9c02", "versionNum": 2},
    {"id": "jver_31ab", "versionNum": 1}
  ],
  "deployments": [
    {"id": "dep_5e7d", "versionId": "jver_9c02", "trigger": "manual", "status": "enabled"}
  ]
}

versions is newest-first and holds up to the 5 most recent; latestVersionId is always the newest one. A judge with no deployments entry whose status is enabled has nothing running — that is the one to call deploy_judge on. (It may still have disabled entries: those are deployments that were turned off, not judges that were never deployed.)

Deploying without a version id

judge_id is the only required argument. Omit version_id and the judge’s latest version is resolved for you:

{"judge_id": "judge_a41f", "trigger_policy": "manual"}

A version number works too, so {"judge_id": "judge_a41f", "version_id": "1"} deploys version 1 — you don’t have to translate it into jver_31ab yourself. Version ids are matched before numbers, so an id made only of digits still wins.

The reply names what was actually deployed, which matters when you let the server pick:

{
  "id": "dep_5e7d",
  "judgeId": "judge_a41f",
  "versionId": "jver_9c02",
  "resolvedVersionId": "jver_9c02",
  "trigger": "manual",
  "status": "enabled",
  "successThreshold": null,
  "alreadyDeployed": false
}

Deploying a version that is already enabled with the same trigger and the same pass cutoff returns that existing deployment with alreadyDeployed: true rather than creating a second one, so a retry is safe. Changing trigger_policy or success_threshold does create a new deployment — different behaviour is not a duplicate, and silently reusing the old row would drop the argument you changed. Omitting success_threshold expresses no preference, so it still matches a deployment that has one.

If a version genuinely can’t be resolved, the error lists the judge’s real version ids (Its versions are: version 2 = jver_9c02; version 1 = jver_31ab) so the next call succeeds. Never retry deploy_judge with an invented id.

Running an eval from a judge id

run_eval takes deployment_id, but if you only have the judge, pass that instead and its single enabled deployment is used:

{"judge_id": "judge_a41f"}

The reply carries resolvedDeploymentId alongside the run id, status and total, so you always know which deployment actually ran. Two cases are refused rather than guessed at:

SituationWhat run_eval answers
The judge has no enabled deploymentAn error saying so, telling you to call deploy_judge for that judge first
The judge has more than one enabled deploymentAn error naming every candidate — deployment id, its version id and its trigger — for you to pass as deployment_id

By default a run covers what the deployment’s sampling filter selects. Pass sample_size to cap this run at the N most-recent eligible traces without editing the deployment — e.g. sample_size=1000 to score 1000 traces. The response total is the number actually queued, which is smaller when fewer traces are eligible. success_threshold independently overrides what counts as a pass for that one run.

Datasets, versions and exports

ToolKindDoes
list_datasetsReadThe agent’s datasets: id, name, item count, source kind, tags, and the cluster a from-cluster dataset was built from
get_dataset_itemsReadA dataset’s live items (input, expected output, captured output, source trace), with optional q search and kind filter. Reports total / returned / complete so a page is never mistaken for the set
list_dataset_versionsReadA dataset’s immutable versions, newest first, and which one is golden (returned separately as goldenVersion / goldenVersionId)
get_dataset_version_itemsReadOne version’s frozen snapshot items — same shape as get_dataset_items, but it never changes under a running evaluation. version is the integer version number
export_datasetReadA dataset export as a manifest — see Exports return a manifest
export_dataset_versionReadOne immutable version’s export, as the same manifest plus which version it is and whether it is golden
create_datasetWriteA dataset. With session_ids / trace_ids it is created and populated from that selection, optionally as an immutable golden v1 (golden: true); with neither, an empty manual dataset
add_to_datasetWriteAdds session_ids / trace_ids to an existing dataset (by id or name); reports how many were added versus skipped as duplicates
create_golden_versionWriteSnapshots a dataset’s current items into a new immutable version and marks it golden (pass golden: false for a plain snapshot). One golden version per dataset — goldening a new one un-goldens the previous

create_golden_version is the step between curating a dataset and evaluating against it: a pre-prod evaluation and a model sweep both replay a frozen version, so results stay comparable across runs. If create_preprod_run refuses with “dataset has no golden version”, this is the tool you missed.

Annotations

ToolKindDoes
add_annotationWriteA pass/fail ground-truth label on a trace or session, with an optional critique, is_gold, and a failure_mode_id / cluster_id tie-in. Re-labelling the same target supersedes the prior label

Pre-prod evaluations

Use these to run a pre-prod evaluation yourself. When you are proving one specific remediation’s fix, run_verification in the fix loop does the same job in one call.

ToolKindDoes
list_preprod_runsReadThe agent’s runs, newest first: status, version label, dataset and frozen dataset version id, gate, progress, regression count. The dataset version id is what tells you which runs are comparable. Filter by status or dataset_id
get_preprod_run_itemsReadThe run’s frozen prompt list — one row per golden item with its itemId, input and expected output. itemId is what get_preprod_trajectory diffs on
create_preprod_runWriteCreates a run over a dataset’s golden version at a candidate version_label. Created in awaiting_traces — it runs nothing until you start it. runner_mode: "push" means Neens calls your endpoint itself and requires agent_connection_id; "runner" means Neens waits for your own harness’s traces. Gate it with min_pass_rate / max_regressions
start_preprod_runWriteStarts a created run, routing itself on the run’s own runner_mode — you do not have to pick. Refuses a run that is already terminal
cancel_preprod_runWriteCancels a run that is still awaiting traces or running. A terminal run is refused rather than rewritten

Comparing results

ToolKindDoes
get_preprod_comparisonReadThe candidate-vs-baseline verdict for one run: the candidate’s pass rate, the regression set (baseline passed, candidate failed), the new passes, and cost/latency/step deltas. The baseline is the run’s own — a prior run or a prod window — and baseline overrides which
get_preprod_metricsReadPer-metric average and pass rate for one run. This enforces “faithfulness must stay ≥ 0.8” where the run’s single aggregate pass rate cannot
compare_preprod_runsReadLines up two or more runs over the same frozen golden version: per-run pass rate, average score and cost/latency/step traits, plus a matrix keyed by golden prompt with one cell per run. Name one as baseline_run_id and each row also reports which runs regressed against it
get_preprod_trajectoryReadDiffs how the agent behaved on one golden prompt between two runs — each run’s captured trajectory as an ordered tool-call/LLM step list, aligned, with added/removed/changed steps. Use it to explain why an item regressed
⚠️

Three reading rules these tools depend on you honouring:

  • compare_preprod_runs requires runs that snapshot the same dataset version. Mismatched runs are refused rather than aligned on prompts that are not the same prompts.
  • Read regressionCount, not the length of regressions. The row lists are capped evidence; the count is the finding.
  • null is not zero. A passRate of null means nothing was scored yet, a cost delta of null means neither side had a priced session, and a sessionId of null in a trajectory means that run captured nothing for the prompt — which is not the same as “no change”.

The remediation backlog

ToolKindDoes
list_remediationsReadThe remediation backlog ranked by priority, with a stats rollup. Filter by status, workState, clusterId, failureModeId or type
get_remediationReadOne remediation with its simulations and what-changed correlation — the evidence to review before accepting it
generate_remediationWriteGenerates a grounded fix for a cluster_id or failure_mode_id. Degrades deterministically when the agent has no LLM connection
update_remediationWriteTransitions a remediation’s status (proof lane) and/or work_state (triage lane) and/or labels

The fix loop

The six tools that let a coding agent pull a fix, prove it, and report the result back — see Fix loop over MCP for the round trip.

ToolKindDoes
list_open_remediationsReadOpen fixes ranked by priority — excludes verified/closed and archived unless include_resolved: true. Each row carries its lifecycle status, any PR/commit already reported, and the last verification run id
get_fix_bundleReadThe fix bundle for a remediation_id: the paste-ready markdown brief, the pre-generated neens eval run CI command, the gate policy, the proof evals and the acceptance criteria
run_verificationWriteRuns a real pre-prod PUSH evaluation against your preview deploy (endpoint_url or agent_connection_id) at a required version_label, scores it with the proof judges, and gates on regressions. Polls up to wait_seconds (default 90, max 300) for a verdict
get_verification_runReadPolls a verification run_id for the terminal verdict: status, gate pass/fail, reasons, regressions, pass rate and score aggregate
report_fix_statusWriteSets a remediation’s status / work_state and records the pr_url and commit_sha where the fix landed
record_fix_mergeWriteTells Neens a fix PR was merged so it can open a post-merge efficacy close-out watch and measure the failure mode’s real production volume before versus after

Orchestrated fix runs

ToolKindDoes
start_fix_runWriteLaunches a Neens-orchestrated, eval-verified fix run for an accepted remediation_id: Neens proposes the patch, applies it on a branch, verifies it with pass^k pre-prod runs, and opens a PR only if green
get_fix_runReadPolls a fix run_id: status (queued, applying, verifying, pr_opened, failed, drafted, cancelled), attempt count, pass^k eval report, verifying judges, PR url, failure report

Prompt optimization

ToolKindDoes
start_prompt_optimizationWriteLaunches an offline, GEPA-style prompt-optimization run over a failure mode’s historical traces — no live traffic. A winner is emitted as a normal prompt_change remediation, never a deploy
get_prompt_optimizationReadPolls an optimization run_id: status, baseline versus best held-out score, rollouts and iterations spent, the candidate lineage, the verdict, and the emitted remediation id when the winner cleared the bar

Model sweeps

ToolKindDoes
start_model_sweepWriteLaunches a model sweep: one frozen golden set, N arms (each {label, agent_connection_id}), k runs each, pass^k per arm. Refused if the estimate exceeds budget_usd or the deployment’s ceilings
get_model_sweepReadPolls a sweep_id: status, the pinned conditions, the cost estimate, and a per-arm summary (model, status, greens/k, run counts)
get_model_sweep_comparisonReadThe sweep’s verdict: which model to ship, the per-arm leaderboard with sample size, 95% confidence interval and cost per case, and a verdict per agent

Exports return a manifest, not the file

export_dataset and export_dataset_version deliberately never put the export body in the tool result. A real golden export is far past the 20,000-character result cap, so inlining one would go through the shrinking described below and hand you a file that is missing rows but still parses as valid JSON. An export you cannot tell is incomplete is worse than no export tool, so you get everything needed to act on it — and a URL for the bytes:

{
  "datasetId": "ds-7f2a91",
  "datasetName": "checkout-timeout-v1",
  "format": "json",
  "rowCount": 4821,
  "sampleFields": ["id", "kind", "traceId", "sessionId", "input", "expectedOutput", "output"],
  "csvColumns": ["kind", "trace_id", "session_id", "input", "expected_output", "output"],
  "itemsChecksum": "sha256:9d31c0…",
  "bytes": 3841902,
  "sampleRowCount": 3,
  "sample": [ /* the first 3 rows, each field capped at 300 characters */ ],
  "complete": false,
  "retrieval": {
    "method": "GET",
    "path": "/api/datasets/ds-7f2a91/export",
    "csvPath": "/api/datasets/ds-7f2a91/export?format=csv",
    "note": "…"
  }
}
FieldWhat it is good for
rowCountThe exact number of rows in the export. Counted from the full body, never from sample
completetrue only when sample is every row and no field in it was truncated. On false, treat sample as a preview and fetch the export
itemsChecksumsha256 over the canonical row list, in order. Two exports carry the same rows if and only if these match — the cheapest way to tell whether a dataset changed since you last pulled it. It is not the digest of the downloaded file: retrieval.path wraps the rows in a {dataset, items} envelope, and csvPath is a different format again, so hashing either will not reproduce it
bytesSize of the canonical row list, so you can decide whether to fetch the export at all
sampleFieldsThe fields each row of sample carries. The JSON export additionally carries raw and createdAt (plus versionId on a version export)
csvColumnsThe CSV header served at retrieval.csvPath — six snake_case columns, and note there is no id
retrieval.path / retrieval.csvPathWhere to GET the whole export

To fetch the full export, call the retrieval.path over ordinary HTTP with the same bearer token you connected the MCP server with — no extra credential, and the same agent scope:

curl -H "Authorization: Bearer nk_live_your_project_key" \
  "https://YOUR_NEENS_HOST/api/datasets/ds-7f2a91/export" -o dataset.json
 
# …or as CSV
curl -H "Authorization: Bearer nk_live_your_project_key" \
  "https://YOUR_NEENS_HOST/api/datasets/ds-7f2a91/export?format=csv" -o dataset.csv

If you only need a few rows and not a file, get_dataset_items (or get_dataset_version_items) pages them properly and reports total alongside returned.

Walkthroughs

Three end-to-end flows you can run today. Each shows the prompt you’d type at your coding agent and the wire traffic underneath, so the same recipe works whether you’re driving a chat client or writing the JSON-RPC by hand.

Triage a failure mode and curate a golden dataset

The everyday loop: find what’s failing, look at real examples, record verdicts, and freeze the result into a dataset your judges can evaluate against forever.

Prompt. “Using the neens tools, find the biggest new failure mode in this agent, pull 20 example traces from its failure set, mark the ones that are genuinely broken as fail, and collect them into a golden dataset called checkout-timeout-v1.”

Find the failure mode

list_failure_modes takes no arguments. It returns the clustering run’s readiness plus a ranked modes array — each with id, label, sessionCount, isNew and rootCauseHypothesis — so the agent can pick a target without a second call.

Read its root cause

get_failure_mode with the cluster_id from the previous step returns the full root cause, the suggested remediation, and up to ten memberSessionIds to start from.

{"cluster_id": "cl_7f2a91"}

Pull example traces

list_traces with failure_set: true scopes the search to the clustering failure set. Here is the full tools/call request and reply, so you can see exactly what crosses the wire:

{
  "jsonrpc": "2.0",
  "id": 3,
  "method": "tools/call",
  "params": {
    "name": "list_traces",
    "arguments": {
      "failure_set": true,
      "issue_mode": "Checkout tool timeout",
      "status": "error",
      "limit": 20
    }
  }
}

The reply is a standard MCP result whose single text block is the JSON tool result:

{
  "jsonrpc": "2.0",
  "id": 3,
  "result": {
    "content": [{
      "type": "text",
      "text": "{\"total\":83,\"traces\":[{\"id\":\"sess_4d19\",\"agentName\":\"checkout-agent\",\"status\":\"error\",\"startedAt\":\"2026-07-28T09:14:22Z\",\"turnCount\":6,\"failureMode\":\"Checkout tool timeout\"}]}"
    }],
    "isError": false
  }
}

total is the number of traces that matched, not the number returned — traces holds at most limit rows.

Record verdicts

add_annotation, once per trace the agent judged. target_type is session by default; is_gold marks the label authoritative, and tying it to the cluster keeps the evidence linked:

{
  "target_type": "session",
  "target_id": "sess_4d19",
  "verdict": "fail",
  "critique": "Tool call to /checkout exceeded the 30s budget; agent retried instead of failing over.",
  "is_gold": true,
  "cluster_id": "cl_7f2a91"
}

Freeze the dataset

create_dataset in one shot — creating it and populating it from the selection, with golden: true to snapshot an immutable version 1:

{
  "name": "checkout-timeout-v1",
  "description": "Traces from the checkout tool-timeout failure mode, July 2026.",
  "session_ids": ["sess_4d19", "sess_51c0", "sess_6b77"],
  "golden": true
}

Grow it later with add_to_dataset (same session_ids / trace_ids, plus dataset — the id or the name), which reports added and skipped so re-running it is safe.

Score against it

create_judge → deploy_judge → run_eval turns the dataset into a repeatable score, and each step hands the next one the id it needs. create_judge returns the judge id; deploy_judge needs nothing but that ({"judge_id": "judge_a41f"} deploys the latest version) and returns the deployment id; run_eval accepts either that deployment id or the judge id on its own. Use list_judges instead of create_judge to score with a judge that already exists — one call returns its version ids and its deployment ids together. See Deploying without a version id.

Read what the judge decided

run_eval returns a run id. get_eval_run opens it — per-status task counts, success rate, and a page of scored tasks with each target’s score, label and reason:

{"run_id": "run_9c21", "limit": 20}

For the failures across every judge rather than one run, list_score_rows filters the raw score rows — {"status": "fail", "metric_key": "primary_score", "sort": "lowest"} puts the worst first. get_judge_runs lists a judge’s history when you need an older run’s id.

From a failure cluster to a pre-prod comparison

The full evaluation loop, using nothing but MCP tools: pick a failure cluster, freeze its traces into a golden set, evaluate a candidate against it, and compare the result to a baseline.

Prompt. “Using the neens tools, take the biggest failure cluster in this agent, freeze its traces into a golden dataset, run a pre-prod evaluation of branch fix/checkout-timeout against it, and tell me whether anything regressed versus the current baseline.”

Pick the cluster

list_clusters takes no required arguments and returns each cluster’s id, label, description and sessionCount. get_cluster_exemplars then shows the members nearest its centroid so you can confirm you picked the right one before pulling hundreds of traces:

{"cluster_id": "cl_7f2a91", "limit": 5}

Pull every trace in it

list_traces with cluster_id returns the cluster’s exact membership. This is the filter to reach for — matching a cluster’s label through issue_mode silently returns the wrong set as soon as a label is regenerated:

{"cluster_id": "cl_7f2a91", "limit": 50}

Narrow further with any of the filters in Traces and sessions — min_duration_ms for the slow tail, model for one model’s share of the cluster, tool_name for the tool that failed.

Freeze them into a golden set

create_dataset collects the trace ids, then create_golden_version snapshots them into an immutable version. Evaluations replay the frozen version, which is what keeps two runs comparable:

// create_dataset
{"name": "checkout-timeout-v1", "session_ids": ["sess_4d19", "sess_51c0", "sess_6b77"]}
 
// create_golden_version
{"dataset": "checkout-timeout-v1", "name": "v1", "notes": "Traces from cluster cl_7f2a91"}

Check the result with list_dataset_versions — it reports goldenVersion and goldenVersionId explicitly, which is exactly what the next step resolves.

Create the run

create_preprod_run takes the dataset (id or name) and the candidate version_label — the branch, commit or deploy under test. With no dataset_version_id it resolves the golden version for you. Add the gate you want enforced:

{
  "name": "checkout-timeout candidate",
  "dataset_id": "checkout-timeout-v1",
  "version_label": "fix/checkout-timeout@a1b2c3d",
  "runner_mode": "push",
  "agent_connection_id": "conn_preview",
  "min_pass_rate": 0.9,
  "max_regressions": 0
}

It comes back in awaiting_traces and runs nothing yet. get_preprod_run_items shows the frozen prompt list it will replay.

Start it

start_preprod_run with {"run_id": "ppr_9d31"}. It reads the run’s own runner_mode and does the right thing: a push run is executed by Neens against your endpoint, a runner run moves to running and waits for your harness’s traces. Poll list_preprod_runs for progress, or cancel_preprod_run to stop one.

Read the verdict

get_preprod_comparison with the run id gives the candidate’s pass rate, the regression set, the new passes, and cost/latency/step deltas versus the baseline:

{
  "runId": "ppr_9d31",
  "candidate": {"scored": 30, "passed": 27, "passRate": 0.9},
  "regressionCount": 2,
  "newPassCount": 5,
  "matrixTotal": 30,
  "regressions": [ /* capped evidence rows */ ],
  "deltas": {"latencyMs": {"before": 1400, "after": 1180, "delta": -220}}
}

get_preprod_metrics adds the per-metric breakdown — the one that enforces a threshold on a single metric rather than on the aggregate.

Compare candidates against each other

Once you have a second run over the same golden version, compare_preprod_runs lines them up side by side and marks per-row regressions against whichever you declare the baseline:

{"run_ids": ["ppr_9d31", "ppr_9d44"], "baseline_run_id": "ppr_9d31"}

For any row that regressed, get_preprod_trajectory diffs how the agent actually behaved on that one prompt between the two runs — the step list, aligned, with what was added, removed or changed:

{"run_id": "ppr_9d44", "baseline_run_id": "ppr_9d31", "item_id": "dvi_0875bc"}

That is the answer to why it regressed, not just that it did.

Close the loop on a fix

The differentiator: your coding agent writes the fix in your repo, and Neens proves it with a real evaluation against your preview deploy.

Prompt. “Pick the highest-priority open remediation from neens, get its fix bundle, implement it on a branch, deploy it to preview, then ask neens to verify the fix at that branch and report the PR back.”

Pick a fix

list_open_remediations — no required arguments — returns the open backlog ranked by priority, each row carrying id, title, type, status, workState, priority, and any prUrl, commitSha or verificationRunId already recorded.

Get the brief

get_fix_bundle with that remediation_id returns everything the coding agent implements from: a paste-ready markdown brief (root cause, typed before/after fix spec, redacted failing exemplars), the pre-generated evalCommand, the gatePolicy, the proof evals, and the acceptanceCriteria.

Implement it — in your repo

Your coding agent makes the change with its own model and your credentials, and pushes a branch to a preview deploy. Neens is not involved and never sees your source.

Prove it

run_verification replays the remediation’s golden dataset against the preview endpoint and gates the result. version_label is required — it’s the branch, commit or preview id under test:

{
  "jsonrpc": "2.0",
  "id": 9,
  "method": "tools/call",
  "params": {
    "name": "run_verification",
    "arguments": {
      "remediation_id": "rem_2c81",
      "version_label": "fix/checkout-timeout@a1b2c3d",
      "endpoint_url": "https://pr-482.preview.example.com",
      "request_shape": "openai_chat",
      "min_pass_rate": 0.9,
      "max_regressions": 0,
      "wait_seconds": 120
    }
  }
}

If the run finishes inside wait_seconds you get the verdict directly:

{"runId":"pre_9d31","status":"completed","passed":true,"reasons":[],"regressions":0,"passRate":0.94,"aggregate":{"scored":50,"passed":47}}

If it doesn’t, you get {"runId": "pre_9d31", "status": "running", "pending": true, "message": "Run still executing; poll get_verification_run with this runId."} — poll get_verification_run with {"run_id": "pre_9d31"} until status is terminal.

Report it back

report_fix_status links the remediation to the code that resolved it:

{
  "remediation_id": "rem_2c81",
  "status": "verified",
  "work_state": "done",
  "pr_url": "https://github.com/acme/checkout-agent/pull/482",
  "commit_sha": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0"
}

After a human merges

record_fix_merge with {"remediation_id": "rem_2c81"} (the pr_url and commit_sha default from the remediation, merged_at defaults to now) opens a post-merge efficacy close-out watch: after the post-deploy window Neens compares the failure mode’s real production volume before and after, and auto-verifies — or flags a regression of — the fix.

Prefer Neens to write the patch too? Swap the middle three steps for start_fix_run → get_fix_run: Neens proposes the patch from the same bundle, verifies it with pass^k pre-prod runs, and opens a PR only if it goes green. A human still merges. See eval-verified fix.

Fix loop over MCP

The six fix-loop tools let a coding agent do more than investigate: it can pull a fix off the Neens backlog, implement it in your repository, ask Neens to prove the fix works, and report the result back — all without Neens ever touching your code. Neens does the diagnosis and the verification; your coding agent, in your repo, with your credentials, does the edit and opens the pull request. A human still reviews and merges.

This is what sets the Neens MCP apart: other observability MCP servers stop at read-only triage. The Neens run_verification turns “here’s a suggested fix” into “here’s a fix that provably passes the same evals the failure originally broke.”

The round trip

#StepTool
1Pull a fix off the backlog — anything not yet verified or closed, and not archivedlist_open_remediations
2Get the full brief: root cause, typed before/after fix, anonymized failing examples, a ready-to-run proof-eval commandget_fix_bundle
3Apply it in your own repository — your coding agent, your model, your credentials. Neens is not involved and never sees your source(none)
4Point Neens at your preview deploy at a version label; it replays the remediation’s golden dataset as a pre-prod evaluation and gates the resultrun_verification
5Poll for the terminal pass/fail and eval report if step 4 didn’t finish in its wait budgetget_verification_run
6Attach the pull-request URL and commit SHA, so the remediation is permanently linked to the code that resolved itreport_fix_status
7After a human reviews and merges, open the post-merge efficacy close-out watch on real production volumerecord_fix_merge

Close the loop on a fix walks the same seven steps with real arguments and the wire traffic.

⚠️

run_verification calls your preview endpoint over HTTP. Neens never receives or stores repository credentials; the pull-request URL and commit SHA you attach are the only code references it keeps.

How it works

Each tools/call builds a short-lived in-process client that re-enters an existing Neens REST endpoint (for example, list_traces calls GET /sessions, add_annotation calls POST /review), forwarding your Authorization header. The Neens tenant resolution and each route’s authorization gate run per call, so there is no separate access path and no way for an agent to exceed the connecting key’s permissions.

The server is stateless and advertises only the tools capability. Reads are available to any agent-scoped credential; writes are admitted for an agent key on the specific non-destructive write routes, staying leak-safe because the key is pinned to a single agent.

Results are compacted, never truncated mid-JSON

A tool result feeds a model’s context window, not a UI, so every result is compacted on the way out. This is visible to you, and it is worth knowing before you build on a response shape.

CapValue
Maximum characters in one tool result20,000
Strings longer than this are truncated (with a trailing …)600 characters
Lists are capped at this many entries30
Fields dropped entirelyembedding, sparkline, confidenceBasis
Error message lifted out of a failed responsefirst 400 characters

The result is always valid JSON. If the compacted payload still exceeds 20,000 characters, Neens repeatedly halves every list and re-serializes — up to 20 passes — rather than cutting the string off somewhere arbitrary. Any result that went through that shrinking carries a top-level marker so a client can tell:

{"total": 4821, "traces": [ /* … */ ], "truncated": "…(truncated)"}

So a "truncated" key means there was more — narrow the query (a tighter limit, a time bound, a single id) rather than assuming you saw everything. In the pathological case where there are no lists left to shrink, a successful payload becomes {"truncated": "…(truncated)", "data": "<sliced JSON as a string>"} — still parseable, deliberately obvious. Any top-level error, ref or status it carries stays a real key beside those two rather than going into data. A failed call is handled differently, and never loses its reason: see A truncated failure still tells you why.

”The call failed” vs. “the run I asked about failed”

These are different facts and Neens reports them in different places. Read them in this order:

  1. isError on the JSON-RPC result answers did the tool call work?
  2. Fields inside the payload — status, error — answer what did I learn about the thing I asked for?

A failed call is never a protocol error and never crashes the session: the reply carries "isError": true and the text block holds {"error": "<message>"}, so the agent can read it and recover. A payload status is present only when the failure came back from the underlying REST route — a non-2xx adds {"status": <code>}. The failures raised before any route is reached (an unknown tool name, a missing required argument, a tool that refuses its own arguments, an executor that threw) carry error alone, so key off isError and treat status as optional. A 403 always returns the same fixed sentence rather than echoing the route’s own message — the credential’s permissions are not something to probe by reading error text.

A payload field named error is data, not a verdict. get_fix_run, get_prompt_optimization, get_model_sweep and their start_… siblings all project the run’s own error string at the top level. It is null on a healthy run and a real message on a run that broke — and in both cases the tool call itself succeeded, so isError is false.

A healthy run — the call worked, and so did the run:

{
  "jsonrpc": "2.0", "id": 7,
  "result": {
    "isError": false,
    "content": [{ "type": "text", "text": "{\"id\":\"fxr_9c21\",\"status\":\"pr_opened\",\"attempt\":1,\"passK\":2,\"prUrl\":\"https://github.com/acme/agent/pull/412\",\"failureReport\":null,\"error\":null}" }]
  }
}

A run that failed — the call still worked, and error is how you find out why:

{
  "jsonrpc": "2.0", "id": 8,
  "result": {
    "isError": false,
    "content": [{ "type": "text", "text": "{\"id\":\"fxr_9c22\",\"status\":\"failed\",\"attempt\":3,\"passK\":2,\"prUrl\":null,\"failureReport\":\"pass^2 not met: 1/2 green\",\"error\":\"patch did not apply to origin/main\"}" }]
  }
}

A call that failed — no payload to read, just the reason:

{
  "jsonrpc": "2.0", "id": 9,
  "result": {
    "isError": true,
    "content": [{ "type": "text", "text": "{\"error\":\"fix run not found\",\"status\":404}" }]
  }
}

How to handle a tools/call reply, in order:

  1. If isError is true, the call did not happen — parse error (and status, if present) and decide whether to retry. Retrying is only useful for a 5xx; a 403, a 404 or a missing argument will fail identically forever.
  2. Otherwise you have a real payload. If the tool reports on a run (get_fix_run, get_prompt_optimization, get_model_sweep), branch on the payload’s status field, then read error for the reason when that status is a failure. get_verification_run is the exception: it reports a gate rather than a run error, so its detail is passed plus reasons[] and it projects no error field at all.
  3. Never treat the presence of an error key as a failure — on a healthy run it is present and null.
⚠️

Up to and including Neens 0.10.0, a payload carrying "error": null was misreported as "isError": true, so a successful get_fix_run / get_prompt_optimization / get_model_sweep looked like a failed call. If your client works around that by ignoring isError for those tools, remove the workaround — isError is now the authoritative answer for every tool.

get_trace is the one read that spells the field differently: a trace’s own error message rides as errorMessage (with per-tool-call error strings inside toolCalls[]). That name predates the rule above and is kept for compatibility.

The in-app Assistant runs the same substrate over its own, tighter policy — 6,000 characters per result, 300-character strings, 20-entry lists, and it additionally drops the evidence, artifact, proposal, proof and raw-output fields that the MCP surface keeps. That is deliberate: the Assistant summarizes for a human reading chat, while an MCP client asked for those payloads on purpose and is the one that has to act on them.

An unexpected failure is redacted to a reference id

A tool can fail in a way nobody wrote a message for — the database is unreachable, an upstream connection is refused, a query names a column that isn’t there. Those failures still come back as an ordinary failed call ("isError": true, a text block holding {"error": …}), but the message is deliberately the same fixed sentence every time, plus a ref:

{
  "jsonrpc": "2.0", "id": 11,
  "result": {
    "isError": true,
    "content": [{ "type": "text", "text": "{\"error\":\"the tool failed unexpectedly (ref: 4b1c8ad02e7f)\",\"ref\":\"4b1c8ad02e7f\"}" }]
  }
}

The underlying exception is not relayed. Its message routinely carries the server’s database file path, an internal hostname and port, or the name of a tenant’s ClickHouse database — and a tool result travels straight into an agent’s context window and whatever that client stores, which is a one-way trip. So the detail stays server-side and the ref is the handle to it.

For an agent, ref is what to quote. There is nothing to parse and nothing to probe: treat the call as failed, do not retry it blindly (an unexpected failure repeats until an operator fixes it), and if you report the problem to a human, include the ref verbatim.

The same rule applies one layer up. If the JSON-RPC dispatch itself fails, the protocol error is {"code": -32603, "message": "Internal error (ref: <id>)"} — same handle, same reason.

Operators: finding the detail. Every redacted failure writes one ERROR line to the neens.agent_tools logger on the server that handled the call, carrying the full exception message, its traceback, the tool name and the same ref. Grep your application logs for the id the agent quoted:

# container / compose
docker compose logs web | grep 4b1c8ad02e7f
# kubernetes
kubectl logs deploy/neens-web | grep 4b1c8ad02e7f

The line reads agent tool failure ref=<id> where=tool:<tool name>: <ExceptionType>: <message>, followed by the traceback. There is no flag that turns the detail back on for the client — the redaction is the contract, and the log is where the answer lives.

Failures Neens does have words for are unchanged and still say what to do: an unknown tool name, a missing required argument, a tool refusing its own arguments, a 403, a 404 and every other non-2xx all come back with their own message (and a status, when a REST route produced it). Only the unanticipated ones are redacted.

A truncated failure still tells you why

Compaction and failure reporting are two rules that used to be able to contradict each other. They no longer can, and the guarantee is worth writing your client against:

Whenever isError is true, the text block parses to a JSON object with a top-level error key holding a non-empty string. Truncation can shorten that string — never move it, never drop it.

The reason is the last thing a result gives up. When a failure is too large for the cap, Neens sacrifices in this order: the bulk payload is halved and then demoted into a data string, the truncated marker goes, status goes, and what remains is the reason — shortened with the usual trailing … if it has to be, but never emptied. A redacted failure’s ref (above) is never sliced, because half a correlation id identifies nothing: it survives whole past status, and is given up whole only after the reason has already been shortened. So a large failure looks like this:

{
  "jsonrpc": "2.0", "id": 11,
  "result": {
    "isError": true,
    "content": [{ "type": "text", "text": "{\"error\":\"upstream refused the patch: pass^2 not met on attempt 3 of 3\",\"status\":502,\"truncated\":\"…(truncated)\",\"data\":\"{\\\"raw\\\":{\\\"attempts\\\":[…\"}" }]
  }
}

error and status are real keys you can read directly. truncated says context was dropped, and data is whatever was demoted — a sliced JSON dump carried as a plain string, so treat it as opaque diagnostic text and never as something to parse.

For a client author:

  1. Branch on isError first. If it is true, JSON.parse the text block and read error — it is always there and always non-empty, so there is no “unknown failure” branch to write.
  2. Read status if present to decide whether to retry (5xx yes; 403/404/a bad argument will fail identically forever). Its absence means the failure happened before any route was reached, not that the status was hidden.
  3. Treat truncated as informational: context was dropped and data, if present, is partial. Do not parse data, and do not read the failure out of it. Note that truncated present means the reason is whole — the marker is given up before the message is, so the two never appear abridged together.
  4. A reason ending in … is a shortened message, not a different one — it is always a prefix of the full text. It also means every droppable key had already been dropped, so do not read the absence of status as a signal in that case.

The in-app Assistant makes the same promise under a 6,000-character cap, so a failure the Assistant reports is readable for exactly the same reason.