A four-dimensional quality framework for testing MCP servers.
periscope goes beyond "does the tool return a response" — it tests whether your tools work correctly, whether an LLM actually reaches for them when it should, whether their descriptions are clear enough to be understood unambiguously, and whether they hold up under load.
Four testing dimensions:
- Functional — correctness across happy-path, boundary, invalid, and adversarial inputs
- Routing — does the LLM prefer your tools over answering from parametric knowledge?
- Clarity — are tool names, descriptions, and parameter schemas unambiguous to an LLM?
- Performance — latency percentiles and throughput under smoke, ramp, spike, and soak profiles
Works with any MCP server over both stdio and Streamable HTTP transports. Single binary, zero infrastructure — results persist to a local SQLite file.
From zero to your first periscope run in under 10 minutes.
-
Install the
periscopebinary with Go:go install github.com/agenthands/periscope/cmd/periscope@latest
-
Build the example MCP server (a tiny
echoserver used by the bundled test cases):go build -o echo-server ./testdata/integration/echo_server/
-
Create a minimal
periscope.yamlin the current directory. Replace/path/to/echo-serverwith the absolute path to the binary you just built:version: "1.0" transport: type: stdio stdio: command: "/path/to/echo-server" # Must be an absolute path — relative paths are rejected at config-load time. judge: provider: anthropic model: "claude-sonnet-4-20250514" apiKeyEnv: "ANTHROPIC_API_KEY" temperature: 0.0 maxTokens: 2048 embeddings: provider: voyage model: "voyage-3-lite" apiKeyEnv: "VOYAGE_API_KEY" tests: paths: - docs/examples/testcases/functional-echo.yaml thresholds: functional: passRate: 0.95 reporting: formats: [markdown] outputDir: "./test-results" baselineDb: "./test-results/baseline.sqlite"
-
Export your API keys.
periscope runin v1.0 constructs the embedder at startup regardless of--dimension, so both keys are required for every run:export ANTHROPIC_API_KEY='sk-ant-...' # judge (Anthropic Messages API) export VOYAGE_API_KEY='pa-...' # embeddings (required for every v1.0 run — see docs/INSTALL.md §6)
If either key is missing, periscope fails fast at startup with
environment variable <NAME> is not setand exits non-zero before any test runs. Lazy embedder construction (functional-only runs without a Voyage key) is tracked for v1.1. -
Run the functional dimension against the echo server:
periscope run --config periscope.yaml --dimension functional --tags smoke
You should see output shaped like this:
=== functional [PASS] score=1.0000 Overall: PASS Total: 1 Passed: 1 Failed: 0 Errored: 0 Skipped: 0What this means: A line reading
Overall: PASSmeans every gate for the selected dimension cleared its threshold, and the binary exits with code0so CI keeps moving. Opentest-results/<RunID>.mdfor the full Markdown report including per-case detail and the exact threshold each gate evaluated.
Each dimension targets a different failure mode. You can run one at a time (with --dimension), or let periscope run execute every dimension your config enables.
-
Functional — Validates that each tool returns the correct output across four input categories: happy-path, boundary, invalid, and adversarial. The gate metric is pass rate (per-dimension fraction of cases that passed), and the default threshold is
0.95. Run this dimension on every PR — it is the fastest to execute and the most likely to catch regressions in your tool's own logic. Assertion modes range from exact-string matching to LLM-judge semantic comparison, so you pay only for the judgement you need. -
Routing — Measures whether an LLM actually chooses your tool when a user prompt should trigger it, as opposed to answering from its own parametric knowledge. The gate metrics are correct selection rate (did the LLM call the right tool?), parametric fallback rate (did it skip tools entirely?), parameter accuracy, and position-bias total-variation distance (does swapping tool order change the answer?). Run this dimension when you change a tool's name, description, or parameter schema, and whenever you add a tool that might compete with an existing one.
-
Clarity — Evaluates whether tool names, descriptions, and parameter schemas communicate unambiguously to an LLM. Three evaluation methods are available:
self-reflection(the judge rates understanding of the description),adversarial-paraphrase(judges a misleading rewrite side-by-side), andhallucination-detection(checks whether the LLM invents parameters that do not exist). The gate metrics are overall score, disambiguation rate, and hallucination rate. Run this dimension after any description rewrite and before shipping a new tool. -
Performance — Exercises your server under one of four load profiles —
smoke,ramp,spike, orsoak— and records latency with an HDR histogram so percentiles are accurate at p50, p95, and p99. The gate metrics are the configured SLA latencies (per-case or global), error rate, throughput (rps), and regression against the stored baseline. Run this dimension on a schedule (for example nightly) to catch performance drift before users feel it. The stdio transport is clamped to 10 virtual users so you cannot accidentally fork-bomb your local subprocess.
The simplest path is go install github.com/agenthands/periscope/cmd/periscope@latest, which drops a periscope binary into $(go env GOBIN) (or $GOPATH/bin). Prebuilt binaries for the release tags are attached to each GitHub release if you prefer not to compile. For local development, git clone the repo and run make build to produce ./periscope in the repo root.
See docs/INSTALL.md for full prereqs, verification steps, and API-key setup.
Periscope exposes 6 subcommands: run (execute tests), calibrate (tune the LLM judge against human-labeled fixtures), baseline (freeze the current run as the regression baseline), report (re-render a past run from SQLite), discover (probe an MCP server and list its tools), and history prune (retention maintenance). The common workflow:
periscope run --config periscope.yaml
periscope calibrate run --rubric clarity-self-reflection
periscope baseline --update
periscope report --format markdownSee docs/USAGE.md for every flag, expected output, YAML test case schema, and config reference.
Runnable test case YAMLs — one per dimension — live under docs/examples/testcases/: functional-echo.yaml, routing-echo.yaml, clarity-echo.yaml, and performance-echo.yaml. Each targets the bundled echo server at testdata/integration/echo_server/ so you can execute them end-to-end without pointing at a real production MCP server.
See docs/examples/testcases/ for runnable test cases and docs/examples/ci/ for GitHub Actions and GitLab CI snippets.
periscope run --ci is threshold-gated: the binary exits 0 when every configured dimension clears its gates, and exits 1 the moment any gate fails — so your pipeline fails the same run your PR broke. JUnit XML output (reporting.formats: [junit]) populates the native Tests tab in GitHub Actions and GitLab CI, and the Markdown report doubles as a ready-to-post PR comment via $GITHUB_STEP_SUMMARY or a GitLab comment bot.
See the CI Integration section of docs/USAGE.md for full details and copy-pasteable snippets in docs/examples/ci/.
Contributing guide (coming soon) — will land as docs/CONTRIBUTING.md in a later milestone. For now, see open issues and open a PR against main.
Built in Go. Designed for CI/CD.