Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 1 addition & 26 deletions .craftsmanship-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -5742,16 +5742,6 @@
"kind": "method-size",
"detail": "main"
},
{
"file": "scripts/verify_mcp_hosts.py",
"kind": "file-size",
"detail": "exceeds 300-line cap"
},
{
"file": "scripts/verify_mcp_hosts.py",
"kind": "method-size",
"detail": "main"
},
{
"file": "scripts/wiki_backfill_ids.py",
"kind": "method-size",
Expand Down Expand Up @@ -6527,21 +6517,6 @@
"kind": "method-size",
"detail": "test_entity_dedup_is_domain_scoped"
},
{
"file": "tests_py/infrastructure/test_stdio_transport.py",
"kind": "method-size",
"detail": "TestGuardedRunDeliversLateResponse.test_guarded_run_delivers_the_late_response"
},
{
"file": "tests_py/infrastructure/test_stdio_transport.py",
"kind": "method-size",
"detail": "TestUnguardedRaceCharacterization.test_unguarded_low_level_run_drops_the_late_response"
},
{
"file": "tests_py/infrastructure/test_stdio_transport_wiring.py",
"kind": "file-size",
"detail": "exceeds 300-line cap"
},
{
"file": "tests_py/infrastructure/test_supersession_read_path.py",
"kind": "unsourced-constant",
Expand Down Expand Up @@ -6808,4 +6783,4 @@
"detail": "TOTAL"
}
]
}
}
9 changes: 9 additions & 0 deletions .craftsmanship.conf
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,12 @@
# false positive, not an enforcement of this project's actual size policy.
# Downgrade to advisory; all other rules stay at their strict defaults.
SEV_FILE_TOO_LONG=advise

# requirements/*.txt are machine-generated, hash-pinned exports of uv.lock
# (scripts/generate_pip_constraints.py — each file's own header says
# "GENERATED ... do not hand-edit"). coding-standards.md §4.1 names an
# explicit exception for auto-generated files; this config is that exception
# applied to the local scanner, which has no way to detect it on its own
# (its skip-list is path/extension based, and .txt is not a recognized
# data/lock extension). Appended, not replaced — every existing skip stays.
CRAFT_SKIP_PATHS="${CRAFT_SKIP_PATHS}|^requirements/"
109 changes: 71 additions & 38 deletions mcp_server/__main__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
"""Bootstrap entry point for the methodology-agent MCP server.

Uses FastMCP (3.x) for protocol handling — supports MCP 2025-11-25 natively.
Bridges existing async handler functions as FastMCP tools.
Uses the ``mcp`` SDK's native ``MCPServer`` (2.0.0+) for protocol handling.
Bridges existing async handler functions as MCP tools. mcp 2.0.0 folded
FastMCP's decorator API into the SDK itself (``mcp.server.mcpserver
.MCPServer``, the documented successor to ``fastmcp.FastMCP``); this module
was fastmcp-based before that migration (see git history + issue: PR #331,
mcp 1.29.0 -> 2.0.0).

Usage:
python -m mcp_server
Expand All @@ -14,11 +18,11 @@

# Eager-import scipy/sklearn (pulled in transitively by ``sentence_transformers``,
# a mandatory dependency — see pyproject.toml) on the main thread, before the
# FastMCP event loop exists. embedding_engine._ensure_model() lazily does
# MCP server's event loop exists. embedding_engine._ensure_model() lazily does
# ``from sentence_transformers import SentenceTransformer`` on first
# embed/encode call; on Windows, that first import of scipy/sklearn's C
# extensions can deadlock CPython's import lock when it runs inside a
# FastMCP/anyio worker thread instead of the main thread — the worker never
# extensions can deadlock CPython's import lock when it runs inside an
# anyio worker thread instead of the main thread — the worker never
# recovers, and every subsequent write (remember) hangs identically since
# the import lock stays held. Importing here first makes the worker-thread
# import a no-op sys.modules lookup. Cost: ~1.6s on a cold disk/page cache
Expand All @@ -27,7 +31,9 @@
# session), dropping to near-zero once the OS has these .so/.pyc files
# cached — pays once per fresh boot, not once per server restart.
# source: cdeust/Cortex#92 (rapporteur mbe14, validated fix, Windows 11,
# Python 3.13.13, reproduced on FastMCP 3.2.4 and 3.4.4).
# Python 3.13.13, reproduced on FastMCP 3.2.4 and 3.4.4 — the underlying
# thread-vs-import-lock hazard is a CPython property, not FastMCP-specific,
# so the mcp 2.0.0 migration does not remove the need for this preload).
try: # pragma: no cover — defensive; sentence-transformers is mandatory
# (pyproject.toml), so these transitive imports are expected to exist,
# but a degraded/partial install must not prevent server startup.
Expand All @@ -44,8 +50,7 @@
)

import anyio
import fastmcp
from fastmcp import FastMCP
from mcp.server.mcpserver import MCPServer

from mcp_server import (
mcp_prompts,
Expand All @@ -62,11 +67,10 @@
from mcp_server.tool_profile_middleware import ToolProfileMiddleware
from mcp_server.core.wiki_axis_registry import configure_default_wiki_root
from mcp_server.core.wiki_classifier import configure_user_rules_provider
from mcp_server.handlers._tool_meta import apply_param_docs
from mcp_server.handlers._tool_meta import apply_output_schemas, apply_param_docs
from mcp_server.infrastructure.config import WIKI_ROOT
from mcp_server.infrastructure.mcp_client_pool import close_all
from mcp_server.infrastructure.otel_exporter import build_otel_exporter
from mcp_server.infrastructure.stdio_transport import run_stdio_drained
from mcp_server.infrastructure.upstream_availability import (
codebase_upstream_available,
prd_upstream_available,
Expand Down Expand Up @@ -101,14 +105,22 @@
ACTIVE_PROFILE = tool_profiles.resolve()

# ── Server Instance ────────────────────────────────────────────────────────

mcp = FastMCP(
#
# ToolProfileMiddleware must be constructed and passed here, at
# MCPServer.__init__: mcp 2.0.0's ``middleware`` list is a constructor-only
# parameter (no post-construction ``add_middleware`` exists, unlike
# FastMCP). Registering tools/prompts onto ``mcp`` after this point is still
# safe — the middleware inspects the runtime call/list dispatch, not the
# registration-time tool set.

mcp = MCPServer(
name="methodology-agent",
version="1.0.0",
# Per-profile instructions: the server describes itself in the shape it was
# started in (issue #177 criterion 3). FULL keeps the historical onboarding
# line ("Call query_methodology…").
instructions=tool_profiles.instructions(ACTIVE_PROFILE),
middleware=[ToolProfileMiddleware(ACTIVE_PROFILE)],
)

# ── Tool Registration ──────────────────────────────────────────────────────
Expand All @@ -133,7 +145,7 @@ def merged_schemas() -> dict[str, dict]:
}


def register_all(mcp: FastMCP, *, codebase: bool, prd: bool) -> None:
def register_all(mcp: MCPServer, *, codebase: bool, prd: bool) -> None:
"""Wire every tool registry onto ``mcp``.

The 50 standalone tools always register (ground truth:
Expand All @@ -153,10 +165,13 @@ def register_all(mcp: FastMCP, *, codebase: bool, prd: bool) -> None:
tool_registry_advanced.register(mcp)
tool_registry_wiki.register(mcp)
tool_registry_ingest.register(mcp, codebase=codebase, prd=prd)
# FastMCP derives input schemas from function signatures; project the
# hand-written inputSchema parameter descriptions onto them so clients
# (and registry graders) see documented parameters.
# MCPServer derives input AND output schemas from the function signature
# alone (return type for output; mcp 2.0.0 has no way to pass a raw
# output JSON Schema at registration time). Project the hand-written
# inputSchema parameter descriptions and outputSchema shapes onto the
# registered tools so clients (and registry graders) see them.
apply_param_docs(mcp, merged_schemas())
apply_output_schemas(mcp, merged_schemas())


register_all(
Expand All @@ -168,19 +183,20 @@ def register_all(mcp: FastMCP, *, codebase: bool, prd: bool) -> None:
# ── Prompts + profile enforcement (issues #176, #177) ───────────────────────
#
# Prompts render their step summaries from the same schema map as tools/list
# (no drift). ToolProfileMiddleware filters the advertised tool/prompt surface
# to ACTIVE_PROFILE and REJECTS calls to tools the profile excludes — hiding a
# (no drift). ToolProfileMiddleware (constructed into ``mcp`` above, at
# MCPServer.__init__ time) filters the advertised tool/prompt surface to
# ACTIVE_PROFILE and REJECTS calls to tools the profile excludes — hiding a
# destructive tool while still executing it on call would be a security hole,
# not a token optimisation (#177 criterion 5). Under the default FULL profile
# the middleware is a pass-through, so existing behaviour is unchanged.
mcp_prompts.register_prompts(mcp, merged_schemas())
mcp.add_middleware(ToolProfileMiddleware(ACTIVE_PROFILE))

# resources/list interop shim (#176 criterion 4): FastMCP 3.2.4 already answers
# resources/list interop shim (#176 criterion 4): the MCP SDK already answers
# resources/list and resources/templates/list with empty arrays and declares
# the capability, so the -32601 failure some clients surface on connect
# (CBM upstream #958) does not occur here — the framework provides the shim.
# Verified 2026-07-25 by an in-memory Client round-trip:
# Verified 2026-07-25 by an in-memory Client round-trip (FastMCP 3.2.4;
# unchanged in the mcp 2.0.0 rewrite this module now runs on top of):
# list_resources() -> [] list_resource_templates() -> []
# No code is needed; recorded here per §8 rather than left implicit.

Expand All @@ -195,23 +211,40 @@ def _shutdown(sig=None, frame=None) -> None:
def main() -> None:
signal.signal(signal.SIGTERM, _shutdown)
signal.signal(signal.SIGINT, _shutdown)
# NOT mcp.run(transport="stdio"): that delegates to FastMCP's
# run_stdio_async, which closes the write stream on stdin-EOF before an
# in-flight request's handler has had a chance to respond (see
# mcp_server/infrastructure/stdio_transport.py's module docstring for
# the exact upstream race + citations). run_stdio_drained is a drop-in
# replacement with the same banner/lifespan/init-options behavior that
# additionally drains in-flight handlers before shutdown.
# A stdio MCP process must initialize without network access. Preserve the
# FastMCP banner (and FASTMCP_SHOW_SERVER_BANNER resolution), but disable
# only its PyPI update lookup: that non-essential request can raise before
# ``initialize`` when the host exports a SOCKS proxy and ``httpx[socks]``
# is absent. Version discovery belongs in an explicit maintenance/doctor
# path, never in the protocol handshake.
# source: fastmcp==3.4.5 settings.check_for_updates +
# utilities/version_check.py::check_for_newer_version.
fastmcp.settings.check_for_updates = "off"
anyio.run(run_stdio_drained, mcp)
# Was mcp_server.infrastructure.stdio_transport.run_stdio_drained, a
# hand-built workaround for a FastMCP 3.4.5 defect: its LowLevelServer
# .run override dropped the base SDK's own `finally:
# tg.cancel_scope.cancel()`, so on stdin EOF the write stream could
# close before an in-flight request's handler (dispatched from the same
# input batch) had a chance to respond.
#
# That workaround is NOT restored here, and the reason is a protocol
# reading rather than a claim that mcp 2.0.0 fixed the race -- it did
# not. Closing stdin IS the MCP shutdown signal (2025-06-18 §Lifecycle
# > Shutdown > stdio: the client "SHOULD initiate shutdown by ... first,
# closing the input stream to the child process"), and the protocol
# defines no drain phase, so a request accepted moments before EOF is
# owed nothing. mcp 2.0.0 duly drops some of them in silence: with the
# handler already returned, `_handle_request` has set
# `answer_write_started` before awaiting the response write, so a cancel
# landing on that write suppresses the shutdown-error frame too
# (reproduced against a BARE mcp 2.0.0 server, no Cortex code, 2026-08-10;
# forced deterministically in tests_py/infrastructure/
# test_stdio_eof_drain.py). Restoring the drain here would mean holding
# the read stream open past real EOF until every accepted id is answered
# -- and then a wedged handler holds shutdown hostage. Real hosts do not
# need it: they close stdin only when tearing the server down. The one
# caller that did need it was our own smoke harness, now fixed where the
# wrong assumption lived (scripts/mcp_host_client.py).
#
# mcp.run_stdio_async() also enters
# the server lifespan internally now (Server.run()'s own
# `async with self.lifespan(self)`), so no separate manual lifespan
# entry is needed either. There is also no more banner/PyPI-update-check
# ceremony to preserve or disable: mcp 2.0.0's MCPServer.run()/
# run_stdio_async() do neither (verified against the installed
# package's source, not assumed).
anyio.run(mcp.run_stdio_async)


if __name__ == "__main__":
Expand Down
10 changes: 5 additions & 5 deletions mcp_server/doctor_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,14 @@
* `DATABASE_URL` presence + URL parse
* PostgreSQL reachable — `SELECT 1` against the configured DSN
* PostgreSQL extensions — enumerate `vector`, `pg_trgm` via pg_extension
* Critical Python deps importable (psycopg, pgvector, fastmcp, pydantic,
* Critical Python deps importable (psycopg, pgvector, mcp, pydantic,
sentence_transformers)

What we explicitly do NOT check (Feynman discipline — say "I don't know"
when a probe is unreliable):
* MCP stdio handshake. Spawning the actual server, sending an
`initialize` JSON-RPC frame, and reading the response is a moving
target (FastMCP version, transport buffering, race against the
target (MCP SDK version, transport buffering, race against the
server's own dependency-install step in launcher.py). A flaky check
is worse than no check — it sends users chasing phantom failures.
Status: not implemented; reported as "I don't know" in --json so the
Expand Down Expand Up @@ -560,7 +560,7 @@ def _check_pg_extensions() -> McpCheck:
# is heavy (downloads ML weights) but session_start hook needs it; we check
# it as warn rather than fail because a non-session-start MCP startup will
# still work without it.
_HARD_DEPS = ("fastmcp", "pydantic", "psycopg", "pgvector")
_HARD_DEPS = ("mcp", "pydantic", "psycopg", "pgvector")
_SOFT_DEPS = ("sentence_transformers",)


Expand All @@ -586,7 +586,7 @@ def _check_critical_imports() -> McpCheck:
error="\n".join(errs),
fix="The launcher auto-installs deps on first run; if this "
"check still fails, run by hand: "
"`pip install fastmcp pydantic psycopg[binary] pgvector`",
"`pip install mcp pydantic psycopg[binary] pgvector`",
)
return McpCheck(
name="critical Python deps",
Expand Down Expand Up @@ -644,7 +644,7 @@ def _skipped_stdio_handshake() -> dict:
return {
"name": "MCP stdio handshake (initialize → response)",
"skipped": True,
"reason": "Spawning the FastMCP server, sending initialize, and "
"reason": "Spawning the MCP server, sending initialize, and "
"reading the response is a flaky probe across versions and racy "
"with the launcher's own dep-install step. Reporting 'I don't "
"know' rather than a false signal.",
Expand Down
Loading