Back to home page

EIC code displayed by LXR

 
 

    


Warning, /swf-monitor/docs/MCP_FASTMCP_MIGRATION_PLAN.md is written in an unsupported language. File is not indexed.

0001 # MCP FastMCP Migration Plan
0002 
0003 Related history:
0004 
0005 - [MCP_STABILIZATION_PLAN.md](MCP_STABILIZATION_PLAN.md)
0006 - [MCP_STABILIZATION_STATUS.md](MCP_STABILIZATION_STATUS.md)
0007 - [MCP.md](MCP.md)
0008 
0009 ## Motivation
0010 
0011 The first stabilization pass made local MCP usable on swf-testbed by enabling
0012 `stateless` mode, bounding the uvicorn worker, simplifying the Apache proxy,
0013 and adding a watchdog. It did not replace the underlying transport. From
0014 `MCP_STABILIZATION_STATUS.md` §"Notes And Caveats":
0015 
0016 > The current `django-mcp-server` adapter still creates and shuts down a
0017 > `StreamableHTTPSessionManager` per request internally. Stateless mode removes
0018 > server-side MCP session dependence and keeps requests short, but it is not a
0019 > full replacement for a correct lifespan-managed ASGI MCP implementation.
0020 
0021 The watchdog masks the residual lifecycle bug class; it does not fix it. This
0022 plan replaces `django-mcp-server` with the official `mcp` Python SDK
0023 (`FastMCP`) running as a dedicated, lifespan-managed ASGI application. After
0024 the migration the watchdog becomes a transitional safety net rather than a
0025 required component.
0026 
0027 ## Starting State
0028 
0029 | Aspect | Today |
0030 |---|---|
0031 | MCP library | `django-mcp-server` |
0032 | ASGI worker | `uvicorn` on `127.0.0.1:8001`, isolated from mod_wsgi Django |
0033 | ASGI entrypoint | `swf_monitor_project.asgi:application` (full Django ASGI app) |
0034 | Stateless flag | `DJANGO_MCP_GLOBAL_SERVER_CONFIG["stateless"] = True` |
0035 | Session manager lifecycle | per-request `StreamableHTTPSessionManager` create/destroy inside the django-mcp-server adapter |
0036 | Tool surface | ~50 tools across `monitor_app/mcp/` modules: `system.py`, `workflows.py`, `ai_memory.py`, `pandamon.py`, `pcs.py`, plus `swf_list_available_tools` registered in `__init__.py` |
0037 | Implicit tool | `get_server_instructions` is registered automatically by django-mcp-server from `DJANGO_MCP_GLOBAL_SERVER_CONFIG["instructions"]`. There is no explicit tool definition for it in `monitor_app/mcp/`. Removing django-mcp-server without porting it deletes the tool silently. Existing client permission lists already reference `mcp__swf-monitor__get_server_instructions` (see `docs/MCP.md`). |
0038 | Server name | `"swf-testbed"` (from `DJANGO_MCP_GLOBAL_SERVER_CONFIG["name"]`) — clients have this hard-coded in `.mcp.json` and Claude Code permission strings |
0039 | URL prefix | `/swf-monitor/mcp/` mounted in `swf_monitor_project/urls.py` |
0040 | Watchdog | `swf-monitor-mcp-watchdog.service` + `.timer`, restarts ASGI on failed probe |
0041 | Auth | none on loopback; Auth0 OAuth scaffolding present-but-disabled (`monitor_app/auth0.py`, `MCPAuthMiddleware`, `.well-known/` route) |
0042 | Live MCP clients | PanDA Mattermost bot, testbed bot, Claude Code, watchdog — all over loopback `http://127.0.0.1:8001/swf-monitor/mcp/` |
0043 
0044 ## Decisions And Defaults
0045 
0046 1. **Loopback bearer auth — yes.** Apache also proxies `/swf-monitor/mcp/`
0047    externally. A single shared-secret bearer token gives parity with the
0048    transport-policy guarantees in the auth section of `MCP.md`. Operational
0049    clients (PanDA bot, testbed bot, watchdog) gain one env var each. Cheaper
0050    and safer than re-arming Auth0.
0051 2. **Auth0 — remove.** `monitor_app/auth0.py`, `MCPAuthMiddleware`, the OAuth
0052    protected-resource metadata view, and the `.well-known/` route are dead
0053    code under the current operational stance ("the public Apache path still
0054    exists, but remote Claude.ai GET/SSE streaming is not an operational
0055    dependency"). Drop them in Phase 3.
0056 3. **Watchdog — keep through bake-in, then retire.** Useful tripwire during
0057    the first stability window. Delete the unit/timer/script after a clean
0058    48–72h post-migration.
0059 4. **URL prefix — unchanged.** `/swf-monitor/mcp/` stays. Two production bots
0060    and outside developer machines have it hard-coded.
0061 5. **Server name — unchanged.** `serverInfo.name` must remain `"swf-testbed"`.
0062    Changing it breaks `.mcp.json` entries and Claude Code permission strings
0063    like `mcp__swf-monitor__*`.
0064 6. **uvicorn worker count — drop to 2.** Current `--workers 4
0065    --limit-concurrency 32` was a defensive setting against the lifecycle
0066    bug. After migration, fewer workers reduce stuck-process and DB-connection
0067    failure modes without measurable throughput loss for this workload. Keep
0068    `--timeout-graceful-shutdown 15` and `TimeoutStopSec=30`.
0069 
0070 ## Out Of Scope
0071 
0072 This migration changes the MCP transport (django-mcp-server → FastMCP) and
0073 the ASGI guard around it. Nothing else. The following are explicitly not
0074 touched and should not be folded into this work:
0075 
0076 - `/swf-monitor/api/corun-callback/` and the rest of the Django REST API —
0077   stay on mod_wsgi.
0078 - Django admin, the PCS web UI, and the SSE message stream — stay on
0079   mod_wsgi.
0080 - `swf-remote` — does not talk to this MCP endpoint.
0081 - The pandabot ↔ corun MCP integration — pandabot is a client of the
0082   separate corun MCP server; unrelated to swf-monitor's MCP transport.
0083 - `DbLogHandler` async logging — already fixed during the stabilization
0084   pass; no further change required.
0085 - The `swf-monitor-mcp-watchdog.service` / `scripts/mcp_watchdog.py` pair
0086   stays in place through Phase 2; retirement is a Phase 3 decision (step
0087   17) gated on bake-in, not on the transport swap.
0088 
0089 ## Phase 1 — Code (dev tree, no service change)
0090 
0091 1. Add `mcp` (the official Python SDK) to `requirements.txt`. Leave
0092    `django-mcp-server` installed alongside until Phase 2 cuts over, so the
0093    running service is unaffected during development.
0094 
0095 2. Move the instructions string out of `DJANGO_MCP_GLOBAL_SERVER_CONFIG` into
0096    a top-level `settings.py` constant so both the FastMCP constructor and the
0097    shim tool read from a single source of truth:
0098 
0099    ```python
0100    # swf_monitor_project/settings.py
0101    MCP_SERVER_NAME = "swf-testbed"
0102    MCP_SERVER_INSTRUCTIONS = """Streaming workflow orchestration testbed for the
0103    ePIC experiment at the Electron Ion Collider.
0104    ...verbatim, no edits to the existing string...
0105    """
0106    ```
0107 
0108 3. Create the FastMCP instance and the compatibility tool. Because the tool
0109    modules already share a single `mcp` import, do this in
0110    `monitor_app/mcp/__init__.py`:
0111 
0112    ```python
0113    from django.conf import settings
0114    from mcp.server.fastmcp import FastMCP
0115 
0116    mcp = FastMCP(
0117        settings.MCP_SERVER_NAME,                # "swf-testbed"
0118        instructions=settings.MCP_SERVER_INSTRUCTIONS,
0119        stateless_http=True,
0120        json_response=True,
0121        streamable_http_path="/",
0122    )
0123 
0124    @mcp.tool()
0125    async def get_server_instructions() -> str:
0126        """Get the swf-monitor MCP server instructions.
0127 
0128        Compatibility tool for clients and permissions lists that previously used
0129        django-mcp-server's server-instruction helper.
0130        """
0131        return settings.MCP_SERVER_INSTRUCTIONS
0132    ```
0133 
0134 4. In each tool module (`system.py`, `workflows.py`, `ai_memory.py`,
0135    `pandamon.py`, `pcs.py`), replace
0136 
0137    ```python
0138    from mcp_server import mcp_server as mcp
0139    ```
0140 
0141    with
0142 
0143    ```python
0144    from monitor_app.mcp import mcp
0145    ```
0146 
0147    No tool function bodies change.
0148 
0149 5. Create `swf_monitor_project/mcp_asgi.py` — a dedicated ASGI app that
0150    replaces `swf_monitor_project.asgi:application` as the uvicorn entrypoint
0151    for the MCP service. It must:
0152 
0153    - call `django.setup()` after setting `DJANGO_SETTINGS_MODULE`
0154    - mount `mcp.streamable_http_app()` under a `Starlette` app with a lifespan
0155      that runs `mcp.session_manager.run()` for the application's lifetime
0156      (this is the fix for the per-request session-manager bug)
0157    - wrap the Starlette app in an ASGI guard that:
0158      - serves a `/health` endpoint with `{"status": "ok"}` (no auth)
0159      - rejects non-`POST` methods with HTTP 405
0160      - validates `Authorization: Bearer <token>` against
0161        `settings.MCP_BEARER_TOKEN`, returning 401/403/503 appropriately, and
0162        using `hmac.compare_digest` for the comparison
0163      - normalizes incoming paths so `/`, `/mcp[/...]`, and
0164        `/swf-monitor/mcp[/...]` all reach the mounted MCP app cleanly
0165        (handles whatever Apache `ProxyPass` strips or keeps)
0166 
0167    `MCP_BEARER_TOKEN` is a new setting read from `production.env`. Generate
0168    with `python -c "import secrets; print('swf_'+secrets.token_urlsafe(32))"`.
0169 
0170 6. Remove `DJANGO_MCP_GLOBAL_SERVER_CONFIG` and `DJANGO_MCP_ENDPOINT` from
0171    `settings.py` once the new constants land. **Keep the MCP URL mount in
0172    `swf_monitor_project/urls.py` through Phase 1.** Removing it now would
0173    break the live django-mcp-server endpoint on the next service restart
0174    (deploys do restart, and uvicorn re-imports the URLconf). The mount
0175    comes out in Phase 2 in the same change that flips the systemd unit so
0176    the old endpoint never goes dark before its FastMCP replacement is in
0177    place. (Auth0 settings stay until Phase 3.)
0178 
0179 7. Write `scripts/mcp_migration_smoke.py` — a parity probe that runs against
0180    two endpoints (the live django-mcp-server URL and a candidate URL) and
0181    exits non-zero if any check fails. See "Parity Checks" below.
0182 
0183    **Auth asymmetry during the parity window.** Live (post `af0292c`) still
0184    allows unauthenticated requests — `MCPAuthMiddleware` only enforces a
0185    token if one is sent. The candidate at `:8013` requires
0186    `Authorization: Bearer <MCP_BEARER_TOKEN>` on every non-`/health`
0187    request. The smoke script must therefore take per-endpoint auth config
0188    (e.g. `--live-token`, `--candidate-token`, either may be empty) rather
0189    than the candidate accepting a temporary "disable auth" env knob. A
0190    migration-only auth bypass on the candidate would have to be removed
0191    later and is exactly the kind of code we don't want shipped.
0192 
0193 8. Stand the candidate ASGI up on a non-conflicting port (suggest `:8013`)
0194    pointed at the same Postgres + production env, run the smoke script
0195    against `8001` (live) and `8013` (candidate), and iterate until all parity
0196    checks pass.
0197 
0198 ## Phase 2 — Cutover
0199 
0200 9. Update `swf-monitor-mcp-asgi.service`:
0201 
0202    - replace `swf_monitor_project.asgi:application` with
0203      `swf_monitor_project.mcp_asgi:application`
0204    - replace `--workers 4 --limit-concurrency 32` with `--workers 2`
0205    - keep `--host 127.0.0.1 --port 8001 --timeout-graceful-shutdown 15
0206      --proxy-headers --forwarded-allow-ips 127.0.0.1` and `TimeoutStopSec=30`
0207 
0208 10. Remove the django-mcp-server URL mount. Drop
0209 
0210     ```python
0211     path("mcp/", include("mcp_server.urls")),
0212     ```
0213 
0214     from `swf_monitor_project/urls.py`. This deletion ships in the same
0215     commit that flips the systemd unit so the live django-mcp-server
0216     endpoint does not stop responding before its FastMCP replacement is in
0217     place.
0218 
0219 11. Distribute `MCP_BEARER_TOKEN` to every client environment *before* the
0220     ASGI restart in step 12. The candidate has been serving authenticated
0221     traffic for parity tests, but the production cutover only succeeds if
0222     these clients hold the token at the moment uvicorn switches:
0223 
0224     - `production.env` for `swf-monitor-mcp-asgi.service` (already set in
0225       Phase 1 step 5)
0226     - `EnvironmentFile` (or inline `Environment=`) on
0227       `swf-panda-bot.service` and `swf-testbed-bot.service`; restart both
0228       bot units after the env edit so the new value is picked up
0229     - `swf-monitor-mcp-watchdog.service` and `scripts/mcp_watchdog.py` —
0230       script must read `MCP_BEARER_TOKEN` and send
0231       `Authorization: Bearer <token>` on its `initialize` and `tools/list`
0232       probes
0233     - local developer environments — add the token to `~/.env` on every
0234       machine running Claude Code against this MCP and reference it from
0235       `.mcp.json` as an `Authorization` header
0236 
0237     Validate with one direct authed `curl` per client host against the
0238     candidate `:8013` before proceeding.
0239 
0240 12. Install the unit and reload (the deploy script does not install systemd
0241     units automatically):
0242 
0243     ```bash
0244     sudo install -o root -g root -m 644 \
0245       /opt/swf-monitor/current/swf-monitor-mcp-asgi.service \
0246       /etc/systemd/system/swf-monitor-mcp-asgi.service
0247     sudo systemctl daemon-reload
0248     sudo systemctl restart swf-monitor-mcp-asgi.service
0249     ```
0250 
0251 13. Verify in this order:
0252 
0253     - `systemctl status swf-monitor-mcp-asgi.service` — active, no restarts
0254     - `curl -s http://127.0.0.1:8001/health` — `{"status": "ok"}`
0255     - `swf-monitor-mcp-watchdog.service` direct probe — `MCP watchdog OK: N
0256       tools` with N matching the candidate count from Phase 1
0257     - PanDA bot journal — `HTTP MCP: N tools`
0258     - testbed bot journal — `Discovered N tools via MCP`
0259     - one human-driven `tools/call` end-to-end via Claude Code
0260 
0261 14. Update `docs/MCP.md`: Architecture, Transport, Settings, and "Adding New
0262     Tools" sections currently still describe django-mcp-server. Rewrite to
0263     describe the FastMCP+ASGI guard architecture and the bearer token. Add
0264     a one-line note that an HTTP 202 response from FastMCP is normal (it is
0265     the ack for client-to-server notification frames such as
0266     `notifications/initialized`) and is not a sign that SSE has returned.
0267 
0268 ## Phase 3 — Cleanup (after 48–72h clean operation)
0269 
0270 15. Remove `django-mcp-server` from `requirements.txt`. Update the deploy
0271     script to uninstall it from the venv on first deploy after this change
0272     (single `pip uninstall -y django-mcp-server`).
0273 
0274 16. Delete `monitor_app/auth0.py`, the OAuth portion of
0275     `monitor_app/middleware.py`, the protected-resource metadata view in
0276     `monitor_app/views.py`, and the `.well-known/` route in
0277     `swf_monitor_project/urls.py`. Drop the `AUTH0_*` settings from
0278     `settings.py`. Also remove the now-dead `LocationMatch
0279     "^/swf-monitor/\.well-known/"` block from `apache-swf-monitor.conf`
0280     (and any `ProxyPass`/`<Location>` entries that referenced the OAuth
0281     metadata URL) so Apache config and Django URL conf stay aligned.
0282 
0283 17. Decide on the watchdog. If two clean weeks have elapsed with zero
0284     watchdog-induced restarts, delete `scripts/mcp_watchdog.py`,
0285     `swf-monitor-mcp-watchdog.service`, and
0286     `swf-monitor-mcp-watchdog.timer`, and disable the timer on the host.
0287 
0288 ## Parity Checks (must all pass before Phase 2 cutover)
0289 
0290 The Phase 1 smoke script (`scripts/mcp_migration_smoke.py`) probes both the
0291 live endpoint and the candidate, and asserts:
0292 
0293 1. **Tool name set equality.** `set(tools/list against live)` ==
0294    `set(tools/list against candidate)`. The candidate must include every
0295    name the live server exposes, including django-mcp-server's implicit
0296    defaults. The known item is `get_server_instructions`. If any name
0297    appears live-only, either port it or document that it was unused; do not
0298    delete silently.
0299 
0300 2. **Tool schema equivalence.** For each shared name, `inputSchema`
0301    parameter sets are equal and required-flags match. Description-text
0302    drift is allowed; structural drift fails the check.
0303 
0304 3. **`initialize.serverInfo.name == "swf-testbed"`** on the candidate.
0305    Anything else breaks `.mcp.json` and `mcp__swf-monitor__*` permission
0306    strings on every client.
0307 
0308 4. **`initialize.serverInfo.instructions == settings.MCP_SERVER_INSTRUCTIONS`**
0309    verbatim. The instructions string is what Claude Code surfaces as the
0310    server's system reminder; truncation or whitespace drift is a
0311    regression.
0312 
0313 5. **`tools/call get_server_instructions` byte-equals
0314    `serverInfo.instructions`.** Same source of truth, two surfaces.
0315 
0316 6. **Bearer auth behavior.** A request with no `Authorization` header
0317    returns 401; a wrong token returns 403; a missing server token returns
0318    503; a `GET` returns 405; an unauthenticated `GET /health` returns 200
0319    with `{"status": "ok"}`.
0320 
0321 7. **`swf_list_available_tools` parity with `tools/list`.** The hardcoded
0322    list returned by `get_available_tools_list()` in
0323    `monitor_app/mcp/common.py` is what an LLM sees when it introspects;
0324    `tools/list` is what it gets when it asks. Drift between the two has
0325    bitten before — a tool callable via `tools/call` but missing from the
0326    self-describer is functionally invisible to clients that rely on the
0327    discovery helper. Assert: `set(get_available_tools_list())` ==
0328    `set(tools/list against candidate)`. Reconcile by editing the
0329    hardcoded list, not by silently shipping the gap.
0330 
0331 8. **Bot startup.** After cutover, both PanDA bot and testbed bot journal
0332    lines show the expected tool counts as listed in Phase 2 step 13.
0333 
0334 The smoke script must exit non-zero on any failure and print a clear diff
0335 when a check fails.
0336 
0337 ## Code Templates
0338 
0339 ### `monitor_app/mcp/__init__.py` head
0340 
0341 ```python
0342 """MCP Tools for ePIC Streaming Workflow Testbed Monitor and PanDA Monitor."""
0343 from django.conf import settings
0344 from mcp.server.fastmcp import FastMCP
0345 
0346 mcp = FastMCP(
0347     settings.MCP_SERVER_NAME,
0348     instructions=settings.MCP_SERVER_INSTRUCTIONS,
0349     stateless_http=True,
0350     json_response=True,
0351     streamable_http_path="/",
0352 )
0353 
0354 
0355 @mcp.tool()
0356 async def get_server_instructions() -> str:
0357     """Get the swf-monitor MCP server instructions.
0358 
0359     Compatibility tool for clients and permissions lists that previously used
0360     django-mcp-server's server-instruction helper.
0361     """
0362     return settings.MCP_SERVER_INSTRUCTIONS
0363 
0364 
0365 # (existing imports of tool modules follow, unchanged in shape)
0366 ```
0367 
0368 ### `swf_monitor_project/mcp_asgi.py` shape
0369 
0370 ```python
0371 """Standalone ASGI entrypoint for the swf-monitor MCP server."""
0372 from __future__ import annotations
0373 
0374 import contextlib
0375 import hmac
0376 import json
0377 import os
0378 from typing import Any
0379 
0380 os.environ.setdefault("DJANGO_SETTINGS_MODULE", "swf_monitor_project.settings")
0381 
0382 import django
0383 from django.conf import settings
0384 from starlette.applications import Starlette
0385 from starlette.routing import Mount
0386 
0387 django.setup()
0388 
0389 from monitor_app.mcp import mcp  # noqa: E402
0390 
0391 
0392 def _json_body(value: dict[str, Any]) -> bytes:
0393     return json.dumps(value).encode("utf-8")
0394 
0395 
0396 async def _send_json(send, status: int, value: dict[str, Any], headers=None) -> None:
0397     body = _json_body(value)
0398     response_headers = [
0399         (b"content-type", b"application/json"),
0400         (b"content-length", str(len(body)).encode("ascii")),
0401     ]
0402     if headers:
0403         response_headers.extend(headers)
0404     await send({"type": "http.response.start", "status": status, "headers": response_headers})
0405     await send({"type": "http.response.body", "body": body})
0406 
0407 
0408 class MCPRequestGuard:
0409     """Enforce auth and finite POST JSON-RPC before FastMCP sees a request."""
0410 
0411     def __init__(self, app):
0412         self.app = app
0413 
0414     async def __call__(self, scope, receive, send):
0415         if scope["type"] != "http":
0416             await self.app(scope, receive, send)
0417             return
0418 
0419         path = scope.get("path", "")
0420         if path == "/health":
0421             await _send_json(send, 200, {"status": "ok"})
0422             return
0423 
0424         scope = self._normalize_mcp_path(scope)
0425         method = scope.get("method", "").upper()
0426         if method != "POST":
0427             await _send_json(
0428                 send, 405,
0429                 {"error": "MCP endpoint accepts POST JSON-RPC only",
0430                  "allowed_methods": ["POST"]},
0431                 headers=[(b"allow", b"POST")],
0432             )
0433             return
0434 
0435         headers = self._headers(scope)
0436         auth_header = headers.get("authorization", "")
0437         if not auth_header.startswith("Bearer "):
0438             await _send_json(send, 401, {"error": "Authorization required"})
0439             return
0440 
0441         expected = getattr(settings, "MCP_BEARER_TOKEN", None)
0442         if not expected:
0443             await _send_json(send, 503, {"error": "MCP token not configured"})
0444             return
0445 
0446         if not hmac.compare_digest(auth_header[7:], expected):
0447             await _send_json(send, 403, {"error": "Invalid token"})
0448             return
0449 
0450         await self.app(scope, receive, send)
0451 
0452     def _normalize_mcp_path(self, scope):
0453         path = scope.get("path", "")
0454         root_path = scope.get("root_path", "")
0455         for prefix in ("/swf-monitor/mcp", "/mcp"):
0456             if path == prefix or path.startswith(prefix + "/"):
0457                 scope = dict(scope)
0458                 scope["root_path"] = root_path + prefix
0459                 scope["path"] = path[len(prefix):] or "/"
0460                 return scope
0461         return scope
0462 
0463     def _headers(self, scope) -> dict[str, str]:
0464         headers = {}
0465         for key, value in scope.get("headers", []):
0466             headers[key.decode("latin1").lower()] = value.decode("latin1")
0467         return headers
0468 
0469 
0470 @contextlib.asynccontextmanager
0471 async def lifespan(app: Starlette):
0472     async with mcp.session_manager.run():
0473         yield
0474 
0475 
0476 _mcp_application = Starlette(
0477     routes=[Mount("/", app=mcp.streamable_http_app())],
0478     lifespan=lifespan,
0479 )
0480 
0481 application = MCPRequestGuard(_mcp_application)
0482 ```
0483 
0484 ### Updated systemd unit (Phase 2)
0485 
0486 ```ini
0487 [Unit]
0488 Description=SWF Monitor MCP endpoint on ASGI (uvicorn) worker
0489 After=network.target postgresql.service
0490 
0491 [Service]
0492 Type=simple
0493 User=wenauseic
0494 Group=eic
0495 WorkingDirectory=/opt/swf-monitor/current/src
0496 EnvironmentFile=/opt/swf-monitor/config/env/production.env
0497 Environment=DJANGO_SETTINGS_MODULE=swf_monitor_project.settings
0498 ExecStart=/opt/swf-monitor/current/.venv/bin/uvicorn \
0499     swf_monitor_project.mcp_asgi:application \
0500     --host 127.0.0.1 \
0501     --port 8001 \
0502     --workers 2 \
0503     --timeout-graceful-shutdown 15 \
0504     --log-level info \
0505     --proxy-headers \
0506     --forwarded-allow-ips 127.0.0.1
0507 Restart=always
0508 RestartSec=10
0509 TimeoutStopSec=30
0510 
0511 [Install]
0512 WantedBy=multi-user.target
0513 ```
0514 
0515 ## Rollback
0516 
0517 If the new ASGI fails post-cutover, the immediate rollback is a one-line
0518 unit revert:
0519 
0520 ```bash
0521 sudo systemctl edit swf-monitor-mcp-asgi.service
0522 # override ExecStart to point back at swf_monitor_project.asgi:application,
0523 # restoring --workers 4 --limit-concurrency 32
0524 sudo systemctl daemon-reload
0525 sudo systemctl restart swf-monitor-mcp-asgi.service
0526 ```
0527 
0528 The MCP URL mount in `swf_monitor_project/urls.py` (removed in Phase 2
0529 step 10) must be restored before this rollback can serve traffic. Until
0530 Phase 3 deletes `django-mcp-server` from requirements, the venv still has
0531 the old library available.