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.