Chapter 9 explained the protocol. Here you build the server: one FastMCP file that reads a 5,000-row VWO test-case export once and exposes it as three tools the model can call, four resources the app can read, and two prompts the user can pick. It was generated from a structured prompt, then checked by hand in the MCP Inspector.
All three MCP primitives over one CSV, in a 292-line server.py.
5,000
Test cases served
14 columns, keys VWO-1001 to VWO-6000, read once and cached.
11
Inspector checks
The README's click-through checklist, error paths included.
3.4.4
fastmcp version
Pinned in pyproject.toml. The prompt asked for 2.x; the build verified 3.x.
01What you build, and why a tester should
Pasting a 5,000-row CSV into a chat burns the context window and goes stale the moment the file changes. An MCP server lets the model ask for exactly what it needs ("three invite-user cases in User Management") and works in every MCP client without changes. For a tester it is also a new kind of system under test: inputs, outputs, schemas and error paths you can check like any API.
flowchart LR
CSV[(vwo_5000_test_cases.csv)] -->|read once, cached| S[FastMCP server: vwo-testcases]
M[The model decides] -->|tools/call| S
A[The client app] -->|resources/read| S
U[The user picks] -->|prompts/get| S
S --> T[3 tools: search, get, stats]
S --> R[4 resources: schema, all, modules, module/NAME]
S --> P[2 prompts: review, regression suite]
One server, one dataset, all three primitives. stdio is the transport, so a client starts server.py as a subprocess.
Case-insensitive text search over Summary, Description, Steps, Expected Result, Labels and Preconditions, with optional filters; limit 1 to 200
Tool
get_test_case(test_id)
One case by key; 1001 and vwo-1001 also resolve to VWO-1001
Tool
test_case_stats(group_by)
Counts by module, priority, status, test_type, browser or device
Resource
testcases://schema
Row count, primary key, and each column's distinct count and values
Resource
testcases://all
Every row as JSON: bulk data belongs in a resource, not a tool reply
Resource
testcases://modules
The 17 module names with counts: the valid values for the template below
Resource (templated)
testcases://module/{name}
All cases for one module, matched case-insensitively (Reports has 333)
Prompt
review_test_case(test_id)
A senior-QA review rubric on four axes, with the case embedded as JSON
Prompt
generate_regression_suite(module)
The first 40 cases of a module, with "Showing 40 of N" stated in the text
Read the real header first. The column names are not the obvious guesses: the key is Issue Key (not ID), the title is Summary (not Title) and the module is Component (not Module). The build prompt makes the assistant show the schema and wait before it writes any code.
02Live demo: the tool-call playground
Pick a tool, try a case or type your own arguments, and send the tools/call. The tool definitions are the server's real tools/list reply. The answers come from a JavaScript port of server.py and FastMCP's argument checks, run over the same 5,000 rows; on 669 recorded calls to the real server (fastmcp 3.4.4) it produced the same reply, byte for byte.
Watch two columns disagree. The inputSchema check is what a client could verify before sending; the reply is what the server actually does. They are not the same rules.
Tool-call playground: the vwo-testcases serverNo API key needed
Request on stdinReply on stdout The code that answered (server.py)Server stderr (abridged)
Two kinds of error, one shape. Bad types and unknown arguments are rejected by FastMCP before your function runs. Business rules (the 1 to 200 limit, an unknown module) are rejected by your own ToolError. Both reach the client as "isError": true with a readable message, while the traceback stays on stderr.
03How server.py is built
The whole server is one file. Five decisions carry it. First, nothing but JSON-RPC may reach stdout, so logging is pointed at stderr before anything else runs:
Second, the CSV path is resolved, never hard-coded: an environment override first, then three places relative to the file. The data is read once and cached in _CASES with a key index in _BY_ID.
server.py
def_resolve_csv_path() -> Path:
"""Locate the dataset via the env override, then paths relative to this file."""
override = os.environ.get(CSV_ENV_VAR)
if override:
path = Path(override).expanduser()
ifnot path.is_file():
raiseFileNotFoundError(f"{CSV_ENV_VAR}={override!r} is not a readable file")
return path
for candidate in _CANDIDATES:
if candidate.is_file():
return candidate
searched = ", ".join(str(c) for c in _CANDIDATES)
raiseFileNotFoundError(f"{CSV_FILENAME} not found (looked in: {searched}); set {CSV_ENV_VAR} to override")
Third, one decorator per primitive, and the docstring and type hints are functional: FastMCP turns them into the description and the inputSchema the model reads.
FastMCP builds each tool's inputSchema from the type hints and defaults, and its description from the docstring. This is the real schema from the server's tools/list reply.
Fourth, errors are typed and name the valid values, so a wrong guess corrects itself in one more call:
server.py
@mcp.tooldefget_test_case(test_id: str) -> dict[str, Any]:
"""Return one test case by its issue key, for example VWO-1001."""
row = _lookup(test_id)
if row isNone:
raiseToolError(
f"unknown test_id {test_id!r}; expected an issue key such as "f"{next(iter(_BY_ID))} (dataset holds {len(_BY_ID)} cases)"
)
return_expand(row)
Fifth, a templated resource is paired with a plain resource that lists what {name} accepts:
server.py
@mcp.resource("testcases://modules", mime_type="application/json")
defmodules_resource() -> list[ResourceContent]:
"""The valid module names accepted by testcases://module/{name}, with case counts."""
counts = Counter(row[COL_MODULE] for row in_cases())
return_json_resource([{"module": name, "count": n} for name, n in counts.most_common()])
@mcp.resource("testcases://module/{name}", mime_type="application/json")
defmodule_resource(name: str) -> list[ResourceContent]:
"""All test cases belonging to one module, matched case-insensitively."""
hits = _module_rows(name)
ifnot hits:
raiseResourceError(f"unknown module {name!r}; read testcases://modules for the valid list")
return_json_resource([_expand(row) for row in hits])
Prompts are templates that the server fills in; nothing reaches a model until a client sends the text. The entry point starts stdio with the FastMCP banner turned off, because the banner would go to stdout:
server.py
if __name__ == "__main__":
try:
log.info("startup: %d test cases cached", len(_cases()))
except ToolError as exc:
log.error("startup: %s", exc)
mcp.run(show_banner=False)
04Resources and prompts, as the server answers them
The playground covers tools. Resources and prompts follow the same request and reply pattern; these replies were recorded from the server over stdio.
resources/read testcases://modules (first 3 of 17)
prompts/get generate_regression_suite {"module": "Reports"} (first two lines)
You are building a regression suite for the Reports module.
Showing 40 of 333 available cases.
resources/list returns the three fixed resources. The templated one appears in resources/templates/list as testcases://module/{name}.
Every resource reply carries "mimeType": "application/json", because each resource returns [ResourceContent(..., mime_type="application/json")].
Resource and prompt errors come back as JSON-RPC errors, not results: reading testcases://module/xyz returned {"code": 0, "message": "unknown module 'xyz'; read testcases://modules for the valid list"}. Tool errors come back as results with isError. Test both shapes.
The regression-suite prompt states its sample ("Showing 40 of 333") instead of cutting silently, so the model knows it is working from part of the data.
05Built from a prompt: Prompt.md
The server was vibe-coded from a structured brief in the course's RICE-POT shape: Role, Instructions, Context, Example, Parameters, Output and Tone, then a gated PROCESS. (The repo README calls it the "RISE-CEPT brief"; the file's own headings spell R-I-C-E-P-O-T.) The instructions read like acceptance criteria:
Prompt.md (Instructions)
Build ONE runnable MCP server in Python using FastMCP that exposes all three MCP
primitives over a local dataset file, vwo_5000_test_cases.csv.
[Critical] Before writing any code, read the CSV header and first 5 rows, then
show me the detected schema (column names + inferred types) and WAIT for my
confirmation. Do not invent or assume column names.
[Mandatory] Load the CSV once at server startup into memory and reuse it across
calls. Do not re-read the file on every request.
[Mandatory] Expose at least 3 TOOLS, e.g.:
- search_test_cases(query: str, module: str | None, limit: int) -> list[dict]
- get_test_case(test_id: str) -> dict
- test_case_stats(group_by: str) -> dict (counts by module/priority/status)
[Mandatory] Expose at least 3 RESOURCES, including one templated URI:
- testcases://schema -> column names and types
- testcases://all -> the full dataset (JSON)
- testcases://module/{name} -> all cases for one module (templated)
[Mandatory] Expose at least 2 PROMPTS:
- review_test_case(test_id) -> asks the LLM to critique coverage/clarity
- generate_regression_suite(module) -> builds a suite from that module's cases
flowchart LR
P1[Phase 1: read the CSV header, show the schema, propose the primitives] -->|you approve| P2[Phase 2: write one file at a time]
P2 --> P3[Phase 3: run it and walk the Inspector checklist]
The PROCESS section of Prompt.md: the assistant stops for approval after the schema, so it cannot build on guessed column names.
Verify the library, not the prompt. The brief asks for "FastMCP (latest 2.x)", but the build pins fastmcp==3.4.4, the latest release when it was written. On 3.x a resource that returns list[dict] raises, and on both versions a bare str silently becomes text/plain. The course notes record smoke-testing every API shape on the installed version before writing the real file.
06Run it, inspect it, register it
Python 3.11+ and uv run the server; Node.js is only needed for the Inspector. No API key: the server is local and read-only.
terminal
cd chapter_10_MCP_Creation_VIBE/testcase-creator-mcp
uv sync # creates .venv with fastmcp==3.4.4uv run python server.py # waits on stdin; logs go to stderrnpx -y @modelcontextprotocol/inspector uv run --directory "$(pwd)" python server.py
# port 6277 busy? use other ports:CLIENT_PORT=6284 SERVER_PORT=6287 npx -y @modelcontextprotocol/inspector uv run --directory "$(pwd)" python server.py
stderr at startup (from the README)
INFO [vwo-testcases] loaded 5000 test cases from .../resource/vwo_5000_test_cases.csv
INFO [vwo-testcases] startup: 5000 test cases cached
Piece
What it does
Command
server.py
The stdio MCP server. It waits on stdin, so it looks like it hangs in a terminal.
uv run python server.py
MCP Inspector
A browser UI to call every tool, resource and prompt by hand
npx -y @modelcontextprotocol/inspector uv run --directory "$(pwd)" python server.py
Claude Code
Registers the server with the CLI
claude mcp add vwo-testcases -- uv run --directory "$(pwd)" python server.py
Claude Desktop
Registers the server in claude_desktop_config.json
Edit the file shown below, then quit and reopen the app
VWO_TESTCASES_CSV
Points the server at another CSV without editing code
VWO_TESTCASES_CSV=/path/to/other.csv uv run python server.py
Prompt.md
The RICE-POT brief the server was generated from
Paste it into your coding assistant and approve each phase
To register it in Claude Desktop, edit ~/Library/Application Support/Claude/claude_desktop_config.json on macOS. Use absolute paths: the app does not inherit your shell PATH, so a bare "command": "uv" fails with spawn uv ENOENT. Find your uv with which uv, then quit and reopen the app.
The schema and the server disagree, both ways.limit has no minimum or maximum in the schema, so 0 passes any client-side check and only the function body stops it. In the other direction FastMCP accepts "3", 3.0 and even true for an integer. Test the server, not the schema.
Search does not look at the key.search_test_cases("VWO-3400") finds nothing, because Issue Key is not in the searched fields. That is what get_test_case is for.
Never print. stdout is the JSON-RPC channel. In recorded runs a stray line made the client log a parse error, and stray text without a newline swallowed the next reply. Log with logging to stderr.
FastMCP 3.x resources. Return list[ResourceContent]. A list[dict] raises TypeError: contents[0] must be ResourceContent, got dict.; a bare str forces text/plain.
A server that seems to hang is fine. A stdio server waits for a client on stdin. Drive it with the Inspector, not a bare terminal.
Absolute paths for Claude Desktop, and a full quit and restart after every config change.
Expected errors leave tracebacks on stderr. The README leaves that noise in on purpose: muting it would also hide real bugs. The client only sees the clean message.
DDrills for the chapter
Playwright drills target the playground on the Page tab (turn on Show locator badges for the data-testid values). The other drills use the Inspector or server.py itself.
Automate the happy pathPlaywright
Choose search_test_cases, click ms-preset-1, and assert the verdict, the first key and the stderr log line.
Expected result
ms-verdict is isError: false, ms-response contains "Issue Key": "VWO-1005", and ms-stderr shows search 'invite user' matched 70 cases, returning 3.
Schema says yes, server says noPlaywright
Fill ms-args with {"query": "invite user", "limit": 0}. Check the schema summary first, then send.
Hint
The check runs on every input event; the reply only changes when you click ms-send.
Expected result
ms-check-summary reads matches the inputSchema, then the reply is isError: true with limit must be between 1 and 200, got 0, raised at server.py lines 153 to 154.
Schema says no, server says yesPlaywright
Send {"query": "invite user", "limit": "3"}. Then try "three" and true.
Expected result
"3": the check fails (limit: expected integer, got string) but the server returns 3 cases. "three": FastMCP rejects it with Input should be a valid integer, unable to parse string as an integer. true: accepted as 1, so one case comes back.
Read the generated schema
From the tools/list reply, what did FastMCP generate for module: str | None = None and for limit: int = 20, and which arguments are required?
Expected result
module: {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null}. limit: {"default": 20, "type": "integer"}. Only query is required, and additionalProperties is false.
Find VWO-3400
Search for VWO-3400, then fetch it with get_test_case. Explain the difference.
Expected result
The search fails with no test cases match query 'VWO-3400' (no filters); try a broader keyword: the key is not one of the six searched fields. get_test_case returns it: [Integrations] API: validate schema of Slack alerts.
Count by priority
Call test_case_stats with group_by set to priority, then to PRIORITY.
Expected result
Both return Medium 2198, High 1546, Low 860, Highest 396 (total 5000): the key is stripped and case-folded before lookup.
Use the templated resource
In the Inspector, read testcases://module/reports (lowercase), then testcases://module/xyz.
Expected result
333 Reports cases, because module names match case-insensitively. The unknown name returns a JSON-RPC error: unknown module 'xyz'; read testcases://modules for the valid list.
Break a resource on purpose
Change modules_resource to return the list of dicts directly instead of _json_resource(...), restart, and read testcases://modules.
Expected result
On fastmcp 3.4.4 the README documents TypeError: contents[0] must be ResourceContent, got dict. Put _json_resource(...) back.
Find the stdout bug
Add print("debug", end="", flush=True) as the first line of test_case_stats and call it from a client. What happens, and how do you fix it?
Expected result
The stray text is glued to the start of the next JSON-RPC line, so the client cannot parse the reply and the call never completes (in a recorded run with the MCP Python SDK client it timed out). Without flush=True Python may hold the text in its stdout buffer and write it later, so the same bug can look intermittent. Use log.info(...), which goes to stderr.
SSolutions: test the playground, then read the server
The Playwright spec drives the playground on this page and passes as written. The other tabs are the exact files from the course repo.
tests/build-mcp-server-demo.spec.ts
import { test, expect } from'@playwright/test';
const URL = 'https://app.thetestingacademy.com/ai/blueprint/learn/build-mcp-server.html';
test('happy path: three invite-user cases from User Management', async ({ page }) => {
await page.goto(URL);
await page.getByTestId('ms-tool').selectOption('search_test_cases');
await page.getByTestId('ms-preset-1').click();
awaitexpect(page.getByTestId('ms-verdict')).toHaveText('isError: false');
awaitexpect(page.getByTestId('ms-response')).toContainText('"Issue Key": "VWO-1005"');
awaitexpect(page.getByTestId('ms-stderr')).toContainText("search 'invite user' matched 70 cases, returning 3");
});
test('the schema allows limit 0, the function body refuses it', async ({ page }) => {
await page.goto(URL);
await page.getByTestId('ms-args').fill('{"query": "invite user", "limit": 0}');
awaitexpect(page.getByTestId('ms-check-summary')).toHaveText('matches the inputSchema');
await page.getByTestId('ms-send').click();
awaitexpect(page.getByTestId('ms-verdict')).toHaveText('isError: true');
awaitexpect(page.getByTestId('ms-response')).toContainText('limit must be between 1 and 200, got 0');
awaitexpect(page.getByTestId('ms-code')).toContainText('raise ToolError');
});
test('the server accepts limit "3" although the schema says integer', async ({ page }) => {
await page.goto(URL);
await page.getByTestId('ms-args').fill('{"query": "invite user", "limit": "3"}');
awaitexpect(page.getByTestId('ms-check')).toContainText('limit: expected integer, got string');
await page.getByTestId('ms-send').click();
awaitexpect(page.getByTestId('ms-verdict')).toHaveText('isError: false');
awaitexpect(page.getByTestId('ms-status')).toContainText('more lenient than the schema');
});
testcase-creator-mcp/server.py
"""MCP server exposing the VWO manual test-case corpus as tools, resources, and prompts."""from __future__ import annotations
import csv
import json
import logging
import os
import sys
from collections import Counter
from pathlib import Path
from typing import Any, Final
from fastmcp import FastMCP
from fastmcp.exceptions import PromptError, ResourceError, ToolError
from fastmcp.resources import ResourceContent
logging.basicConfig(
level=logging.INFO,
stream=sys.stderr,
format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
)
log: Final = logging.getLogger("vwo-testcases")
CSV_ENV_VAR: Final = "VWO_TESTCASES_CSV"
CSV_FILENAME: Final = "vwo_5000_test_cases.csv"
_HERE: Final = Path(__file__).resolve().parent
_CANDIDATES: Final = (
_HERE / "resource" / CSV_FILENAME,
_HERE / CSV_FILENAME,
_HERE.parent / "resource" / CSV_FILENAME,
)
COL_ID: Final = "Issue Key"
COL_MODULE: Final = "Component"
GROUPABLE: Final[dict[str, str]] = {
"module": COL_MODULE,
"priority": "Priority",
"status": "Status",
"test_type": "Test Type",
"browser": "Browser",
"device": "Device",
}
SEARCH_FIELDS: Final = ("Summary", "Description", "Steps", "Expected Result", "Labels", "Preconditions")
ENUM_COLUMNS: Final = ("Issue Type", "Priority", COL_MODULE, "Test Type", "Browser", "Device", "Status")
MAX_LIMIT: Final = 200
SUITE_SAMPLE: Final = 40
_CASES: list[dict[str, str]] = []
_BY_ID: dict[str, dict[str, str]] = {}
def_resolve_csv_path() -> Path:
"""Locate the dataset via the env override, then paths relative to this file."""
override = os.environ.get(CSV_ENV_VAR)
if override:
path = Path(override).expanduser()
ifnot path.is_file():
raiseFileNotFoundError(f"{CSV_ENV_VAR}={override!r} is not a readable file")
return path
for candidate in _CANDIDATES:
if candidate.is_file():
return candidate
searched = ", ".join(str(c) for c in _CANDIDATES)
raiseFileNotFoundError(f"{CSV_FILENAME} not found (looked in: {searched}); set {CSV_ENV_VAR} to override")
def_cases() -> list[dict[str, str]]:
"""Return the dataset, reading and caching the CSV on first use."""global _CASES, _BY_ID
if _CASES:
return _CASES
try:
path = _resolve_csv_path()
with path.open(newline="", encoding="utf-8-sig") as handle:
rows = [{k: (v or"").strip() for k, v in row.items()} for row in csv.DictReader(handle)]
ifnot rows:
raiseValueError(f"{path} contains a header but no data rows")
if COL_ID notin rows[0]:
raiseValueError(f"{path} has no {COL_ID!r} column; found {list(rows[0])}")
except (OSError, ValueError, csv.Error) as exc:
raiseToolError(f"test-case dataset unavailable: {exc}") from exc
_CASES = rows
_BY_ID = {row[COL_ID].upper(): row for row in rows}
log.info("loaded %d test cases from %s", len(rows), path)
return _CASES
def_expand(row: dict[str, str]) -> dict[str, Any]:
"""Return a copy of a row with Steps and Labels split into lists."""
expanded: dict[str, Any] = dict(row)
expanded["Steps"] = [step.strip() for step in row["Steps"].split("|") if step.strip()]
expanded["Labels"] = row["Labels"].split()
return expanded
def_values(column: str) -> list[str]:
"""Return the sorted distinct values held in one column."""returnsorted({row[column] for row in_cases()})
def_match_enum(column: str, value: str) -> str:
"""Resolve a user-supplied value to its canonical casing, or raise ToolError."""for known in_values(column):
if known.casefold() == value.strip().casefold():
return known
raiseToolError(f"unknown {column} {value!r}; valid values: {', '.join(_values(column))}")
def_lookup(test_id: str) -> dict[str, str] | None:
"""Resolve an issue key, or a bare number such as 1001, to its row."""
key = test_id.strip().upper()
if key.isdigit():
key = f"VWO-{key}"_cases()
return _BY_ID.get(key)
def_module_rows(module: str) -> list[dict[str, str]]:
"""Return every row for a module, matched case-insensitively; empty when unknown."""
wanted = module.strip().casefold()
return [row for row in_cases() if row[COL_MODULE].casefold() == wanted]
def_as_json(payload: Any) -> str:
"""Serialise a payload to indented JSON text."""return json.dumps(payload, ensure_ascii=False, indent=2)
def_json_resource(payload: Any) -> list[ResourceContent]:
"""Wrap a payload as JSON resource content, forcing the application/json mime type."""return [ResourceContent(_as_json(payload), mime_type="application/json")]
mcp: Final = FastMCP(
"vwo-testcases",
instructions=(
"Read-only access to a 5000-row VWO manual QA test-case export. ""Use tools to search, fetch, and aggregate; read resources for schema and bulk context."
),
)
@mcp.tooldefsearch_test_cases(
query: str,
module: str | None = None,
test_type: str | None = None,
priority: str | None = None,
limit: int = 20,
) -> list[dict[str, Any]]:
"""Search test cases by free text, optionally filtered by module, test type, and priority."""ifnot1 <= limit <= MAX_LIMIT:
raiseToolError(f"limit must be between 1 and {MAX_LIMIT}, got {limit}")
needle = query.strip().casefold()
ifnot needle:
raiseToolError("query must not be empty; pass a keyword such as 'invite user' or 'keyboard'")
filters = {
COL_MODULE: _match_enum(COL_MODULE, module) if module elseNone,
"Test Type": _match_enum("Test Type", test_type) if test_type elseNone,
"Priority": _match_enum("Priority", priority) if priority elseNone,
}
hits = [
row
for row in_cases()
ifall(row[col] == want for col, want in filters.items() if want)
andany(needle in row[field].casefold() for field in SEARCH_FIELDS)
]
ifnot hits:
applied = ", ".join(f"{col}={want!r}"for col, want in filters.items() if want) or"no filters"raiseToolError(f"no test cases match query {query!r} ({applied}); try a broader keyword")
log.info("search %r matched %d cases, returning %d", query, len(hits), min(len(hits), limit))
return [_expand(row) for row in hits[:limit]]
@mcp.tooldefget_test_case(test_id: str) -> dict[str, Any]:
"""Return one test case by its issue key, for example VWO-1001."""
row = _lookup(test_id)
if row isNone:
raiseToolError(
f"unknown test_id {test_id!r}; expected an issue key such as "f"{next(iter(_BY_ID))} (dataset holds {len(_BY_ID)} cases)"
)
return_expand(row)
@mcp.tooldeftest_case_stats(group_by: str) -> dict[str, Any]:
"""Count test cases grouped by module, priority, status, test_type, browser, or device."""
key = group_by.strip().casefold()
column = GROUPABLE.get(key)
if column isNone:
raiseToolError(f"unknown group_by {group_by!r}; valid values: {', '.join(GROUPABLE)}")
counts = Counter(row[column] for row in_cases())
return {
"group_by": key,
"column": column,
"total": sum(counts.values()),
"distinct": len(counts),
"counts": dict(counts.most_common()),
}
@mcp.resource("testcases://schema", mime_type="application/json")
defschema_resource() -> list[ResourceContent]:
"""Column names, inferred types, enum values, and row count for the dataset."""
rows = _cases()
return_json_resource(
{
"row_count": len(rows),
"primary_key": COL_ID,
"groupable_fields": GROUPABLE,
"columns": [
{
"name": name,
"type": "string",
"distinct": len({row[name] for row in rows}),
"values": _values(name) if name in ENUM_COLUMNS elseNone,
}
for name in rows[0]
],
}
)
@mcp.resource("testcases://all", mime_type="application/json")
defall_resource() -> list[ResourceContent]:
"""The complete test-case dataset as a JSON array."""return_json_resource([_expand(row) for row in_cases()])
@mcp.resource("testcases://modules", mime_type="application/json")
defmodules_resource() -> list[ResourceContent]:
"""The valid module names accepted by testcases://module/{name}, with case counts."""
counts = Counter(row[COL_MODULE] for row in_cases())
return_json_resource([{"module": name, "count": n} for name, n in counts.most_common()])
@mcp.resource("testcases://module/{name}", mime_type="application/json")
defmodule_resource(name: str) -> list[ResourceContent]:
"""All test cases belonging to one module, matched case-insensitively."""
hits = _module_rows(name)
ifnot hits:
raiseResourceError(f"unknown module {name!r}; read testcases://modules for the valid list")
return_json_resource([_expand(row) for row in hits])
@mcp.promptdefreview_test_case(test_id: str) -> str:
"""Ask the model to critique one test case for coverage, clarity, and missing edge cases."""
row = _lookup(test_id)
if row isNone:
raisePromptError(f"unknown test_id {test_id!r}; expected an issue key such as {next(iter(_BY_ID))}")
return (
"You are a senior QA lead reviewing a single manual test case.\n\n"f"{_as_json(_expand(row))}\n\n""Review it on four axes and be specific, citing the field you are criticising:\n""1. Coverage: what scenario does this miss? Name concrete untested paths.\n""2. Clarity: are the steps unambiguous and independently executable?\n""3. Assertability: is the expected result objectively verifiable, or subjective?\n""4. Automation readiness: what blocks this from becoming an automated check?\n\n""Finish with a rewritten version of the weakest field."
)
@mcp.promptdefgenerate_regression_suite(module: str) -> str:
"""Ask the model to build an ordered regression suite from one module's test cases."""
hits = _module_rows(module)
ifnot hits:
raisePromptError(f"unknown module {module!r}; valid modules: {', '.join(_values(COL_MODULE))}")
sample = hits[:SUITE_SAMPLE]
return (
f"You are building a regression suite for the {hits[0][COL_MODULE]} module.\n"f"Showing {len(sample)} of {len(hits)} available cases.\n\n"f"{_as_json([_expand(row) for row in sample])}\n\n""Produce a prioritised suite:\n""1. Select the smallest set of cases that covers the module's critical paths.\n""2. Order them so setup-heavy cases run first and dependent cases follow.\n""3. For each, state the case key, why it is in the suite, and its runtime risk.\n""4. List coverage gaps this module's existing cases do not address.\n\n""Output a markdown table followed by the gap list."
)
if __name__ == "__main__":
try:
log.info("startup: %d test cases cached", len(_cases()))
except ToolError as exc:
log.error("startup: %s", exc)
mcp.run(show_banner=False)
testcase-creator-mcp/pyproject.toml
[project]
name = "vwo-testcases-mcp"
version = "1.0.0"
description = "MCP server exposing a VWO manual QA test-case export as tools, resources, and prompts"
requires-python = ">=3.11"
dependencies = [
"fastmcp==3.4.4",
]
[tool.uv]
package = false
Prompt.md
Follow this shape (verify decorator signatures against the FastMCP version you
actually install before generating the final code):
from fastmcp import FastMCP
mcp = FastMCP("vwo-testcases")
@mcp.tool
def get_test_case(test_id: str) -> dict:
"""Return a single test case by its ID."""
...
@mcp.resource("testcases://module/{name}")
def cases_by_module(name: str) -> list[dict]:
"""All test cases belonging to a given module."""
...
@mcp.prompt
def review_test_case(test_id: str) -> str:
"""Prompt template that asks the model to review one test case."""
...
if __name__ == "__main__":
mcp.run()