Testing
Every published component tests its tools end-to-end the same way: run the packed artifact under
act run --mcp and drive it with a real MCP client, so what the tests observe is exactly what
an agent observes. Both scaffolding templates generate this suite.
The recipe
Section titled “The recipe”test: build ACT="{{act}}" uv run --project e2e pytest e2e/ -vThat’s the entire justfile side. Everything else — the transport, the capability grants, the
“is it actually packed?” check — lives in e2e/conftest.py, where it is reviewable code rather
than an ever-growing command line.
The environment
Section titled “The environment”e2e/pyproject.toml is a virtual project (package = false), so uv builds an environment from
the dependency list without installing your component directory as a distribution:
[project]requires-python = ">=3.12"dependencies = ["fastmcp", "pytest", "pytest-asyncio", "pytest-timeout"]
[tool.uv]package = false
[tool.pytest.ini_options]asyncio_mode = "auto"timeout = 300timeout_method = "thread"The timeout is deliberately generous: a hung test must fail the job rather than run until CI’s own ceiling.
The fixtures
Section titled “The fixtures”e2e/conftest.py gives every test a connected MCP client:
from fastmcp import Clientfrom fastmcp.client.transports import StdioTransport
GRANTS = ["--allow", "wasi:filesystem"] # whatever the component declares
@pytest.fixtureasync def client(act_command, wasm_path): transport = StdioTransport( command=act_command[0], args=[*act_command[1:], "run", str(wasm_path), "--mcp", *GRANTS], keep_alive=False, log_file=LOG_FILE, ) async with Client(transport) as connected: yield connectedFour details that are easy to get wrong:
- Grants are mandatory in CI. The default policy mode is
ask, and a headless run has no prompt channel, so it degrades to deny. Without the--allowflags the failure surfaces as a capability denial that points anywhere except at the missing grant. - The client fixture is function-scoped. A component may keep state across calls within one host process, so sharing a process between tests would let them leak into each other. A genuinely stateless component can widen this.
act’s audit trail goes to a log file. It writes to stderr unconditionally and is not governed byRUST_LOG, so left alone it floods the pytest output. The template captures it and prints it back only when the run fails.- Bound the connect separately.
act run --mcpinstantiates the component before answeringinitialize, so a heavy component takes seconds. The template wraps the handshake in its own timeout so a stalled connect reports itself instead of consuming the whole test budget.
Checking the artifact is packed
Section titled “Checking the artifact is packed”cargo build alone produces a .wasm with no act:component section — and an unpacked artifact
declares no capability ceiling, so every grant is refused as “outside ceiling”. The wasm_path
fixture checks the section rather than just the file:
probe = subprocess.run([*act_command, "inspect", "component-manifest", str(path)], capture_output=True, text=True)name = json.loads(probe.stdout or "{}").get("std", {}).get("name", "unknown")if name in ("", "unknown"): pytest.fail(f"{path} is built but not packed -- run `just pack`")A test
Section titled “A test”async def test_component_exposes_its_tools(client): """A component with no tools is almost always a packaging mistake.""" tools = await client.list_tools() assert len(tools) >= 1
async def test_reverse(client): result = await client.call_tool("reverse", {"text": "hello"}) assert result.content[0].text == "olleh"Quick iteration
Section titled “Quick iteration”For a single call while you’re still writing the tool, act call is faster than a test run:
act call <wasm> reverse --args '{"text":"hello"}'Both templates ship a .github/workflows/ci.yml that runs just build → just test →
just publish. On pull requests only build and test run; publish fires on a push to main, and
the published artifact is then signed with keyless cosign via GitHub OIDC.