Skip to content

Deep Dive: The Server That Passed 249 Tests and Could Not Start

An MCP server for ArgoCD passed 249 tests, unit and integration, and could not start. Importing it raised before a single tool registered, and the suite never saw it, because the tests had swapped out the one component that failed.

The suite covered every tool and every refusal path. I had just finished a review pass and was about to call the work done. Then I ran the thing.

The server died at import

The server, mcp-k8s-observability-argocd-server, gives an AI agent operational control of an ArgoCD cluster through 15 tools in three tiers. Nine reads are always available. Four writes need MCP_READ_ONLY=false and default to dry-run. The two destructive tools, delete and sync with prune, need confirm=true and the application name typed a second time, and MCP_DISABLE_DESTRUCTIVE switches them off for the whole process.

I built it that way because most MCP servers apply the same friction to every operation. Reading a deployment costs what deleting a namespace costs, which is nothing. The launch post in February has the long version.

Starting the server ended here:

mcp.server.fastmcp.exceptions.InvalidSignature:
Unable to evaluate type annotations for callable 'list_applications'

The released version, v0.1.0 from 2026-02-03, had been in that state since release. The v0.2.0 release notes, 2026-08-07, open with it: "on v0.1.0 the server could not actually start."

A type-only name reached a runtime reader

Every tool handler annotated its context parameter with an alias:

async def list_applications(params: ListApplicationsParams, ctx: MCPContext) -> str:

MCPContext was defined inside an if TYPE_CHECKING: block, the usual way to keep an import that only the type checker needs out of the runtime path. With from __future__ import annotations, every annotation is a string, and nothing in Python evaluates it unless asked.

The MCP SDK asks. In the v1 SDK, @mcp.tool() registers the function as soon as the decorator runs, which is when the module is imported. Registration builds the parameter schema the model sees, and to build it the SDK evaluates the annotations against the module's real globals. It does that twice, in two different ways:

# utilities/context_injection.py, find_context_parameter
try:
    hints = typing.get_type_hints(fn)
except Exception:
    return None

# utilities/func_metadata.py, func_metadata
sig = inspect.signature(func, eval_str=True)
# a NameError here becomes InvalidSignature

The first call fails quietly. The second raises. MCPContext did not exist at runtime, so the second call raised NameError, the SDK turned it into InvalidSignature, and the import of the server module stopped.

The pydantic project described this hazard in 2021, when stringified annotations were set to become the default. It quoted the PEP: "Annotations can only use names present in the module scope". A name under TYPE_CHECKING is the one kind of name that is never there.

The mock never read the annotations

The unit tests' setup does this before anything else loads:

sys.modules["mcp.server.fastmcp"] = mock_fastmcp_module

The mock's tool() decorator returned the function unchanged. It had no reason to evaluate type hints, so it never did. The tests called the handlers directly, and the handlers were correct. Registration against the real SDK was the broken part, and the real SDK was the one thing the suite had removed.

The source repo adds one more detail: no test imported argocd_mcp.server at all. The tests imported from config and utils, so the module whose import failed was never imported by anything that ran.

That half mattered more to me than the bug. The bug was one bad annotation. The tests told the truth about every handler, and none of them ever loaded the module that every user loads first.

One test in a fresh process closes the seam

The code fix is two lines per module: import Context at runtime and define the alias at runtime, which server.py had done correctly all along.

The test could not join the existing suite, because that suite patches sys.modules at import and the patch would leak into it. So it imports the server in a clean subprocess, the same way the console entry point does, and counts the tools the real SDK registered:

EXPECTED_TOOL_COUNT = 15

def test_server_module_registers_all_tools_with_real_sdk() -> None:
    result = subprocess.run(
        [
            sys.executable,
            "-c",
            "import argocd_mcp.server as s; print(len(s.mcp._tool_manager.list_tools()))",
        ],
        capture_output=True,
        text=True,
        check=False,
    )
    assert result.returncode == 0, ...
    assert result.stdout.strip() == str(EXPECTED_TOOL_COUNT), ...

Run against the old code, it fails. The v0.2.0 release notes say so.

I also reverted the fix before merging and watched the test fail with the original InvalidSignature. A regression test you have not watched fail is a guess.

The same test caught SDK 2.0

MCP Python SDK 2.0, released 2026-07-28, renamed FastMCP to MCPServer. Importing mcp.server.fastmcp now raises ModuleNotFoundError. In the source repo's account, a later dependency bump resolved the SDK to 2.0. The unit suite, still holding its mock of mcp.server.fastmcp, stayed green. The subprocess test failed at once. The repo now pins mcp>=1.28.1,<2, with a comment saying why.

A v1 server whose tests mock mcp.server.fastmcp gets no signal from those tests when it moves to 2.x.

An uncalled guard has the same shape

The review that found the startup failure found a second defect of the same kind. The server has a single-cluster mode meant to stop an agent from touching anything outside one cluster. The guard was implemented and tested, and no handler called it. It also matched only the name form of the local cluster, in-cluster. ArgoCD applications that set their destination by URL carry https://kubernetes.default.svc, which the guard would not have recognized even once wired in. The fix calls it from every write and destructive handler and accepts both forms.

In both defects the code read correctly and was never run against the thing it was written for. A mock hid the first. An uncalled function hid the second.

When the mock is the right call

The mocked suite still did its job. By the source repo's account it covered every tool and every refusal path, and it needs no cluster to run. Hillel Wayne makes the case for this kind of suite: "Mocks are useful when they make testing more convenient without removing the marginal value of mock-involving tests." Hynek Schlawack's "Don't mock what you don't own" reaches the same place from the other side: wrap the third-party library in a layer you own and mock that layer.

Both leave one seam, between your code and the real dependency, that only a test with the real dependency can see. Kent C. Dodds wrote: "When you mock something you're removing all confidence in the integration between what you're testing and what's being mocked."

If you mock a dependency, you have stopped testing the seam between your code and that dependency. That is usually the right trade, and it is why I still mock. The cost is that the seam now needs its own test, and that test has to use the real component.

What to do if you ship an MCP server

  1. Add one test that imports your server module in a subprocess with the real SDK and asserts the number of registered tools. It fails on any tool annotation the SDK cannot resolve.
  2. Or use the SDK's in-memory client against the real server object: create_connected_server_and_client_session from mcp.shared.memory in v1, Client(mcp) in v2. The server object has to be built with the real SDK first, so registration runs.
  3. Revert the fix once and watch the test fail before you merge it.
  4. Keep any name the SDK reads at runtime. If an annotation on a tool function uses a name, import or define that name outside TYPE_CHECKING.
  5. Before moving to SDK 2.x, run that test first. A suite that mocks mcp.server.fastmcp will pass against an SDK that no longer has it.

The SDK reads your type hints at registration, so an annotation that only exists for the type checker will take the whole process down, and it will do it in a way a mocked suite is structurally unable to notice.

The server is Apache 2.0 and runs on Python 3.11 through 3.14: https://github.com/peopleforrester/mcp-k8s-observability-argocd-server

Sources