Hosted MCP server plugin: per-user, API-gateway access to plugins (#111)#113
Hosted MCP server plugin: per-user, API-gateway access to plugins (#111)#113fatherlinux wants to merge 7 commits into
Conversation
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a FastMCP-based MCP server exposing the caller's own todos and pomodoro
time data as tools, so agents (Claude, Kagetora, Takeda) can list/create/
complete/update todos and read time summaries instead of hand-editing JSON on
Drive.
Architecture (constitution-compliant — server stores nothing):
- FastMCP over Streamable HTTP as a second process behind the in-container
Apache (route /mcp -> 127.0.0.1:5001); Flask/Gunicorn unchanged on 5000.
- Stateless sealed bearer token (mcp_tokens): Fernet-encrypted {refresh_token,
folder_id}; decrypted in memory per request, never persisted.
- Per-user revocation via a token epoch watermark (user_map) — the only
per-user metadata kept. Enable/regenerate/disable advance or gate the epoch.
- Tools reach data through the same storage/plugin functions as the web app.
Enabled plugins contribute tools via register_mcp_tools (plugin_registry).
Surface: list_todos, list_todo_lists, create_todo, complete_todo, update_todo,
get_pomodoros, get_time_summary, tag_pomodoro_to_ticket.
UI: Settings -> MCP Access card to enable/disable, show the endpoint, and mint/
regenerate the one-time token. New env var MCP_TOKEN_SEAL_KEY (derived from
FLASK_SECRET_KEY if unset).
Tests: token seal/unseal/tamper/epoch, todos CRUD, pomodoro aggregation (29 new).
Closes #111
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| SCOPES = [ | ||
| "https://www.googleapis.com/auth/drive.file", | ||
| "https://www.googleapis.com/auth/userinfo.email", | ||
| "https://www.googleapis.com/auth/userinfo.profile", |
There was a problem hiding this comment.
HIGH (Bug Hunter): The mcp_server.py hardcodes the activation of the todos_plugin by calling plugin_registry.activate_extension("todos"). This means the MCP server will always expose the todos tools, regardless of whether the user has explicitly disabled the todos plugin in the web UI. The plugin_registry.get_mcp_tool_registrars() function, which is used to gather tools, checks a global is_active flag within the mcp_server process. By hardcoding activation, the mcp_server bypasses the user's explicit preference to enable or disable the todos plugin, leading to a discrepancy between the web application's enabled plugins and the MCP server's exposed tools.
Suggestion: The mcp_server should not hardcode plugin_registry.activate_extension("todos"). Instead, the require_ctx function (or a similar mechanism) should retrieve the list of user-enabled extension plugins from user_map.py (which would need to be extended to store this information). Then, mcp_server.py would only activate and register tools for those specific plugins for the duration of the request context. This ensures that the MCP server respects the user's plugin activation choices made in the web UI.
Evidence:
plugin_registry.activate_extension("todos")
# ...
for registrar in plugin_registry.get_mcp_tool_registrars():
registrar(mcp, require_ctx)
| Environment=MCP_PORT=5001 | ||
| PassEnvironment=FLASK_SECRET_KEY MCP_TOKEN_SEAL_KEY GOOGLE_CLIENT_ID GOOGLE_CLIENT_SECRET XDG_DATA_HOME | ||
| ExecStart=/usr/bin/python3.12 /app/mcp_server.py | ||
| Restart=on-failure |
There was a problem hiding this comment.
CRITICAL (Bug Hunter): The static/mcp.service systemd unit file specifies ExecStart=/usr/bin/python3.12 /app/mcp_server.py. However, the Containerfile uses FROM registry.access.redhat.com/ubi8/python-39, which provides Python 3.9. This mismatch means that /usr/bin/python3.12 will not exist in the container, causing the mcp.service to fail to start.
Suggestion: Update static/mcp.service to use the correct Python interpreter path for the base image (e.g., /usr/bin/python3 or /usr/bin/python3.9), or modify the Containerfile to install Python 3.12 and ensure it's available at the specified path.
Evidence:
ExecStart=/usr/bin/python3.12 /app/mcp_server.py
| return jsonify({"error": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR | ||
|
|
||
|
|
||
| # ============================================================================= |
There was a problem hiding this comment.
LOW (Bug Hunter): The _public_base_url() function in app.py attempts to determine the public base URL for the MCP endpoint. While it correctly uses X-Forwarded-Proto and X-Forwarded-Host headers (which are set by the Apache proxy in the production setup), if the application is run in a development environment or without a properly configured reverse proxy, these headers might be absent or incorrect. In such cases, it falls back to request.scheme and request.host, which could yield an internal http://127.0.0.1:port URL instead of the public-facing one, leading to agents being configured with an unusable endpoint. The OAUTH_REDIRECT_BASE environment variable provides an override, but relying on request.scheme/request.host as a final fallback can be misleading.
Suggestion: Consider adding a dedicated MCP_PUBLIC_ENDPOINT_URL environment variable that, if set, takes precedence over all other methods for constructing the public base URL. This would provide a clear and explicit way to configure the endpoint for all environments, especially development, without relying on potentially unreliable request headers or internal hostnames.
Evidence:
def _public_base_url():
"""Best-effort public base URL from proxy headers (for the MCP endpoint)."""
oauth_base = os.environ.get("OAUTH_REDIRECT_BASE")
if oauth_base:
return oauth_base.rstrip("/")
proto = request.headers.get("X-Forwarded-Proto", request.scheme).split(",")[0].strip()
host = request.headers.get("X-Forwarded-Host", request.host).split(",")[0].strip()
return f"{proto}://{host}"
| TOKEN_PREFIX = "aqc_v1." | ||
| TOKEN_VERSION = 1 | ||
|
|
||
|
|
There was a problem hiding this comment.
CRITICAL (Security Scan): The MCP_TOKEN_SEAL_KEY is derived from FLASK_SECRET_KEY if not explicitly set. While the documentation advises setting it explicitly in production, this fallback creates a dependency where the security of the MCP access tokens (which contain Google refresh tokens and folder IDs) is directly tied to the security of FLASK_SECRET_KEY. FLASK_SECRET_KEY might not always be generated with sufficient entropy for a cryptographic key, or it might be treated with a lower security posture (e.g., less frequent rotation) than a key used for encrypting highly sensitive credentials. A compromise of FLASK_SECRET_KEY would automatically compromise MCP_TOKEN_SEAL_KEY, leading to the decryption of all active MCP tokens and potential unauthorized access to user data.
Suggestion: Remove the fallback mechanism. Require MCP_TOKEN_SEAL_KEY to be explicitly set in all environments, especially production. If it's not set, the application should refuse to start or mint tokens, rather than deriving it from a potentially less secure or less critical secret. Ensure MCP_TOKEN_SEAL_KEY is a high-entropy, randomly generated key and is rotated regularly.
Evidence:
secret = os.environ.get("FLASK_SECRET_KEY")
if not secret:
raise TokenError("Neither MCP_TOKEN_SEAL_KEY nor FLASK_SECRET_KEY is set")
digest = hashlib.sha256(secret.encode()).digest() # 32 bytes
return base64.urlsafe_b64encode(digest)
| ) | ||
| plugin_registry.register("extension", "todos", todos_plugin, todos_plugin.PLUGIN_METADATA) | ||
| plugin_registry.activate_storage("json-google-drive") | ||
| plugin_registry.activate_extension("todos") |
There was a problem hiding this comment.
HIGH (Performance Check): The mcp_server.py (FastMCP/ASGI application) performs direct blocking I/O operations, specifically network calls to the Google OAuth and Drive APIs (e.g., credentials.refresh, build("drive", ...)). These operations are executed synchronously within an asynchronous context.
Suggestion: Refactor these I/O operations to be asynchronous. For network calls, use asyncio-compatible HTTP clients (e.g., httpx) and Google API libraries that support async operations. Alternatively, offload these blocking operations to a thread pool using starlette.concurrency.run_in_threadpool to prevent blocking the ASGI worker process.
Evidence:
def _build_drive_service(refresh_token):
"""Exchange a refresh token for a live Google Drive service (nothing persisted)."""
client_id = os.environ.get("GOOGLE_CLIENT_ID")
client_secret = os.environ.get("GOOGLE_CLIENT_SECRET")
if not client_id or not client_secret:
raise ToolError("Server is missing Google OAuth configuration")
credentials = Credentials(
token=None,
refresh_token=refresh_token,
token_uri="https://oauth2.googleapis.com/token",
client_id=client_id,
client_secret=client_secret,
scopes=SCOPES,
)
try:
credentials.refresh(Request())
except Exception as exc: # surface a clean re-auth hint to the agent
raise ToolError(
"Google credentials expired or revoked — re-enable MCP access in Acquacotta to mint a fresh token"
) from exc
return build("drive", "v3", credentials=credentials)
| request_creds = get_credentials_from_request() | ||
| if not request_creds: | ||
| return jsonify({"error": "Not logged in"}), HTTPStatus.UNAUTHORIZED | ||
| email = request_creds.get("user_email") |
There was a problem hiding this comment.
CRITICAL (Constitution): The mcp_tokens.py module seals the user's Google refresh token into a bearer token that is then provided to the agent (client). This directly violates the Security Requirements which state 'OAuth tokens stored only in server-side sessions' and 'No client-side storage of credentials'. A refresh token is a highly sensitive OAuth credential and should not be stored client-side.
Suggestion: Revise the authentication mechanism to ensure OAuth refresh tokens are never stored client-side. Consider using short-lived access tokens that are generated server-side and do not contain the refresh token, or implement a secure server-side proxy that handles refresh token management without exposing it to the client/agent.
Evidence:
token = mcp_tokens.seal(email, refresh_token, folder_id, issued_at=epoch)
| const data = await res.json(); | ||
| _mcpRender(data.enabled, data.endpoint); | ||
| } catch (e) { | ||
| // Not logged in / no storage location — leave the card in its default Off state. |
There was a problem hiding this comment.
CRITICAL (Constitution): The mcp_tokens.py module seals the user's Google refresh token into a bearer token that is then provided to the agent (client). This directly violates the Security Requirements which state 'OAuth tokens stored only in server-side sessions' and 'No client-side storage of credentials'. A refresh token is a highly sensitive OAuth credential and should not be stored client-side.
Suggestion: Revise the authentication mechanism to ensure OAuth refresh tokens are never stored client-side. Consider using short-lived access tokens that are generated server-side and do not contain the refresh token, or implement a secure server-side proxy that handles refresh token management without exposing it to the client/agent.
Evidence:
document.getElementById('mcp-token').value = data.token;
| @@ -0,0 +1,16 @@ | |||
| [Unit] | |||
There was a problem hiding this comment.
HIGH (Constitution): The new MCP service running on port 5001 is an HTTP service. The constitution mandates 'Every HTTP service needs a Zabbix web scenario' and implies process checks for all critical services (e.g., 'Gunicorn process check'). The provided diff does not include any updates to monitoring configurations for this new service or its uvicorn process.
Suggestion: Update Zabbix monitoring configurations to include a web scenario for the /mcp endpoint and a process check for the uvicorn process running the mcp_server.py.
Evidence:
[Unit]
Description=Acquacotta MCP server (FastMCP, Streamable HTTP)
After=network.target
[Service]
Type=simple
WorkingDirectory=/app
Environment=MCP_HOST=127.0.0.1
Environment=MCP_PORT=5001
PassEnvironment=FLASK_SECRET_KEY MCP_TOKEN_SEAL_KEY GOOGLE_CLIENT_ID GOOGLE_CLIENT_SECRET XDG_DATA_HOME
ExecStart=/usr/bin/python3.12 /app/mcp_server.py
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
|
|
||
| --- | ||
|
|
||
| ### User Story 3 - Enable/disable the plugin and manage the token (Priority: P1) |
There was a problem hiding this comment.
HIGH (Constitution): The constitution mandates a 'Smoke test: Container starts and the Flask app answers a health check on :8080'. The new MCP service is a separate HTTP service running on port 5001, but there is no explicit mention of extending the smoke test to cover this new service.
Suggestion: Extend the project's smoke test to include a health check for the new MCP service running on port 5001, ensuring it starts correctly and responds to requests.
Evidence:
Independent Test: Enable the MCP plugin in the UI, copy the token into a Claude Code / agent MCP config, then have the agent call `list_todos`, `create_todo`, and `complete_todo`. Verify the changes appear in the Acquacotta web UI and in the Drive `plugins/todos/data.json`, and that the server stored nothing locally.
|
|
||
| Add a hosted MCP endpoint to the existing Flask app as a new plugin. The endpoint speaks MCP over Streamable HTTP, authenticates agents with a stateless sealed bearer token (encrypted `{refresh_token, folder_id}`), and dispatches every tool call through the existing `storage_api` / `todos_plugin` functions. The server stays a pure gateway: no user data or credentials persisted. Tools are contributed by enabled plugins via a new `MCP_TOOLS` descriptor list, so todos and pomodoros ship now and future plugins extend the surface without touching the core. | ||
|
|
||
| ## Technical Context |
There was a problem hiding this comment.
MEDIUM (Constitution): The constitution states 'Backend: Python 3.x with Flask'. The proposed solution introduces FastMCP (an ASGI/Starlette-based framework) running in a separate uvicorn process within the same container, alongside the existing Flask/Gunicorn application. While still Python 3.x, this introduces a second, distinct backend framework and runtime model, deviating from the implied singular 'Flask' backend.
Suggestion: Clarify the architectural decision to include a second Python web framework (ASGI/Starlette) alongside Flask. If this is a new pattern, consider amending the 'Stack Requirements' in the constitution to explicitly allow for multiple Python backend frameworks or specify conditions under which they can be introduced.
Evidence:
**Primary Dependencies**: Flask (existing web app, WSGI), **FastMCP >= 2.0** (Streamable HTTP MCP server — same framework as `mcp-trentina`; ASGI/Starlette), `cryptography` (Fernet) for token sealing, `google-api-python-client` / `gspread` (existing), `google-auth` for refresh-token → access-token exchange
gunicorn.service's PassEnvironment whitelist dropped OAUTH_REDIRECT_BASE, FLASK_ENV, and SESSION_COOKIE_SECURE, so documented local-dev config (and the minted MCP endpoint URL) fell back to the production host. Forward them (plus MCP_TOKEN_SEAL_KEY) and ship the unit in the app image so it lands regardless of the prebuilt base version. Harmless in production where these are unset. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces a hosted MCP server plugin for Acquacotta, allowing AI agents to securely read and manage todo items and pomodoro tracking data. The implementation utilizes FastMCP over Streamable HTTP and employs stateless, sealed bearer tokens to authenticate agents without persisting user credentials on the server. The code review feedback identifies several important improvements: ensuring atomic writes and robust error handling for the shared user mapping file; moving the systemd service file to the rootfs/ directory to adhere to repository style guidelines; enabling the clearing of optional todo fields via empty strings; correcting the order of operations when minting tokens; caching refreshed Google OAuth credentials in memory to reduce latency; and using a generic Python path in the systemd unit file.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| def _read_mapping(): | ||
| path = _mapping_path() | ||
| if path.exists(): | ||
| try: | ||
| with open(path) as f: | ||
| return json.load(f) | ||
| except (json.JSONDecodeError, OSError): | ||
| return {} | ||
| return {} | ||
|
|
||
|
|
||
| def _write_mapping(mapping): | ||
| with open(_mapping_path(), "w") as f: | ||
| json.dump(mapping, f) | ||
|
|
There was a problem hiding this comment.
Writing to user_storage.json is currently non-atomic, which can lead to file corruption or partial reads when the Flask app and the MCP server (running as separate processes) access the file concurrently. Furthermore, catching JSONDecodeError and silently returning {} is highly dangerous: if a read fails due to a temporary sharing violation or partial write, a subsequent write will overwrite the entire file with only the single new entry, permanently wiping out all other users' storage locations.\n\nTo prevent this, perform atomic writes by writing to a temporary file and renaming it using os.replace, and avoid silently swallowing parse errors on non-empty files.
def _read_mapping():\n path = _mapping_path()\n if path.exists():\n try:\n with open(path) as f:\n return json.load(f)\n except (json.JSONDecodeError, OSError) as e:\n if path.stat().st_size == 0:\n return {}\n raise RuntimeError(f"Failed to parse user storage mapping: {e}") from e\n return {}\n\n\ndef _write_mapping(mapping):\n path = _mapping_path()\n temp_path = path.with_suffix(".tmp")\n try:\n with open(temp_path, "w") as f:\n json.dump(mapping, f)\n os.replace(temp_path, path)\n except Exception:\n if temp_path.exists():\n try:\n temp_path.unlink()\n except OSError:\n pass\n raise| # add the /mcp route, install the mcp systemd unit, and enable it. Layered here | ||
| # (not in the base) so it ships without rebuilding the prebuilt base image. | ||
| COPY static/acquacotta-http.conf /etc/httpd/conf.d/acquacotta.conf | ||
| COPY static/mcp.service /etc/systemd/system/mcp.service |
There was a problem hiding this comment.
According to the repository style guide (Rule 11), systemd unit files and init scripts must be placed in the rootfs/ directory. Currently, mcp.service is located in static/mcp.service and copied from there. Please move the service file to the rootfs/ directory to adhere to the repository standards.
COPY rootfs/etc/systemd/system/mcp.service /etc/systemd/system/mcp.service
References
- systemd unit files and init scripts go in rootfs/ directory (link)
| def modify_todo(drive_service, folder_id, todo_id, fields): | ||
| """Update allowed fields on a todo. Returns the updated todo or None if not found.""" | ||
| allowed = {"title", "notes", "priority", "due_date", "list_id"} | ||
| updates = {k: v for k, v in fields.items() if k in allowed and v is not None} | ||
| if "priority" in updates and updates["priority"] not in _VALID_PRIORITIES: | ||
| raise ValueError(f"priority must be one of {_VALID_PRIORITIES}") | ||
| data = read_todos(drive_service, folder_id) | ||
| known_lists = {listing.get("id") for listing in data.get("lists", [])} | ||
| if "list_id" in updates and updates["list_id"] not in known_lists: | ||
| raise ValueError(f"Unknown list_id: {updates['list_id']}") | ||
| for todo in data.get("todos", []): | ||
| if todo.get("id") == todo_id: | ||
| todo.update(updates) | ||
| write_todos(drive_service, folder_id, data) | ||
| return todo | ||
| return None |
There was a problem hiding this comment.
In modify_todo, optional fields like list_id and due_date cannot be cleared because None values are filtered out of updates to prevent overwriting other fields with default values. However, passing an empty string "" to clear them currently raises a ValueError (since "" is not in known_lists).\n\nWe should explicitly handle empty strings "" for due_date and list_id to allow clearing them (setting them to None in the stored todo record) without triggering validation errors.
def modify_todo(drive_service, folder_id, todo_id, fields):\n \"\"\"Update allowed fields on a todo. Returns the updated todo or None if not found.\"\"\"\n allowed = {\"title\", \"notes\", \"priority\", \"due_date\", \"list_id\"}\n updates = {k: v for k, v in fields.items() if k in allowed and v is not None}\n if \"priority\" in updates and updates[\"priority\"] not in _VALID_PRIORITIES:\n raise ValueError(f\"priority must be one of {_VALID_PRIORITIES}\")\n if \"due_date\" in updates and updates[\"due_date\"] == \"\":\n updates[\"due_date\"] = None\n if \"list_id\" in updates and updates[\"list_id\"] == \"\":\n updates[\"list_id\"] = None\n data = read_todos(drive_service, folder_id)\n known_lists = {listing.get(\"id\") for listing in data.get(\"lists\", [])}\n if \"list_id\" in updates and updates[\"list_id\"] is not None and updates[\"list_id\"] not in known_lists:\n raise ValueError(f\"Unknown list_id: {updates['list_id']}\")\n for todo in data.get(\"todos\", []):\n if todo.get(\"id\") == todo_id:\n todo.update(updates)\n write_todos(drive_service, folder_id, data)\n return todo\n return None| epoch = int(time.time()) | ||
| user_map.set_mcp_state(email, enabled=True, epoch=epoch) | ||
| try: | ||
| token = mcp_tokens.seal(email, refresh_token, folder_id, issued_at=epoch) | ||
| except mcp_tokens.TokenError as e: | ||
| return jsonify({"error": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR | ||
| return jsonify({"status": "ok", "enabled": True, "token": token, "endpoint": _mcp_endpoint()}) |
There was a problem hiding this comment.
In _mint_mcp_token(), the order of operations is incorrect. user_map.set_mcp_state is called to enable MCP access before mcp_tokens.seal is executed. If mcp_tokens.seal raises a TokenError (e.g., due to missing configuration), the user's state is left as enabled on disk with the new epoch, but no token is successfully returned to the client. We should only persist the enabled state after the token has been successfully sealed.
epoch = int(time.time())\n try:\n token = mcp_tokens.seal(email, refresh_token, folder_id, issued_at=epoch)\n except mcp_tokens.TokenError as e:\n return jsonify({\"error\": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR\n user_map.set_mcp_state(email, enabled=True, epoch=epoch)\n return jsonify({\"status\": \"ok\", \"enabled\": True, \"token\": token, \"endpoint\": _mcp_endpoint()})| def _build_drive_service(refresh_token): | ||
| """Exchange a refresh token for a live Google Drive service (nothing persisted).""" | ||
| client_id = os.environ.get("GOOGLE_CLIENT_ID") | ||
| client_secret = os.environ.get("GOOGLE_CLIENT_SECRET") | ||
| if not client_id or not client_secret: | ||
| raise ToolError("Server is missing Google OAuth configuration") | ||
| credentials = Credentials( | ||
| token=None, | ||
| refresh_token=refresh_token, | ||
| token_uri="https://oauth2.googleapis.com/token", | ||
| client_id=client_id, | ||
| client_secret=client_secret, | ||
| scopes=SCOPES, | ||
| ) | ||
| try: | ||
| credentials.refresh(Request()) | ||
| except Exception as exc: # surface a clean re-auth hint to the agent | ||
| raise ToolError( | ||
| "Google credentials expired or revoked — re-enable MCP access in Acquacotta to mint a fresh token" | ||
| ) from exc | ||
| return build("drive", "v3", credentials=credentials) |
There was a problem hiding this comment.
Because the MCP server is stateless and doesn't persist access tokens, it instantiates Credentials with token=None and calls credentials.refresh(Request()) on every single tool call. This adds 200-500ms of network latency to every request and can quickly hit Google's token endpoint rate limits.\n\nSince the MCP server runs as a persistent single process, we can cache the refreshed access tokens in memory mapped by their refresh_token for their 1-hour lifetime. This significantly improves performance and responsiveness for consecutive tool calls.
_token_cache = {} # refresh_token -> (access_token, expiry_time)\n\n\ndef _build_drive_service(refresh_token):\n \"\"\"Exchange a refresh token for a live Google Drive service (cached in memory).\"\"\"\n client_id = os.environ.get(\"GOOGLE_CLIENT_ID\")\n client_secret = os.environ.get(\"GOOGLE_CLIENT_SECRET\")\n if not client_id or not client_secret:\n raise ToolError(\"Server is missing Google OAuth configuration\")\n\n import time\n now = time.time()\n cached_token, expiry = _token_cache.get(refresh_token, (None, 0))\n\n if cached_token and expiry > now + 300:\n credentials = Credentials( \n token=cached_token,\n refresh_token=refresh_token,\n token_uri=\"https://oauth2.googleapis.com/token\",\n client_id=client_id,\n client_secret=client_secret,\n scopes=SCOPES,\n )\n else:\n credentials = Credentials(\n token=None,\n refresh_token=refresh_token,\n token_uri=\"https://oauth2.googleapis.com/token\",\n client_id=client_id,\n client_secret=client_secret,\n scopes=SCOPES,\n )\n try:\n credentials.refresh(Request())\n if credentials.token and credentials.expiry:\n _token_cache[refresh_token] = (credentials.token, credentials.expiry.timestamp())\n except Exception as exc:\n raise ToolError(\n \"Google credentials expired or revoked — re-enable MCP access in Acquacotta to mint a fresh token\"\n ) from exc\n\n return build(\"drive\", \"v3\", credentials=credentials)| Environment=MCP_HOST=127.0.0.1 | ||
| Environment=MCP_PORT=5001 | ||
| PassEnvironment=FLASK_SECRET_KEY MCP_TOKEN_SEAL_KEY GOOGLE_CLIENT_ID GOOGLE_CLIENT_SECRET XDG_DATA_HOME | ||
| ExecStart=/usr/bin/python3.12 /app/mcp_server.py |
There was a problem hiding this comment.
Register MCP as an integration plugin (has_mcp flag) so it appears in the Settings plugin list with the same look/feel as the others, instead of a separate hard-coded card. loadPlugins() special-cases has_mcp to render the per-user toggle, endpoint, and one-time token inline via renderMcpCard. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…stMCP 3.x FastMCP 3.x strips the 'authorization' header from the default get_http_headers() view (2.x kept it), so the bearer token never reached require_ctx and every tool call failed with 'Missing bearer token'. Request the full header set explicitly, and pin fastmcp>=3.0,<4 so the runtime matches what we test against. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Summary
Adds a hosted MCP server to Acquacotta so agents (Claude Code, Kagetora, Takeda) get clean, API-driven access to a user's own todos and pomodoro data — instead of hand-editing JSON on Google Drive. Ships as a FastMCP process behind the in-container Apache; the web app is unchanged.
Constitution-compliant: the server is a pure API gateway that stores nothing. Every request carries a sealed bearer token that is decrypted in memory and used to reach the user's own Drive through the same storage/plugin functions the web UI uses.
Architecture
mcp-trentina) as a second process; Apache routes/mcp→127.0.0.1:5001, everything else → Flask/Gunicorn on5000. Single container, no new volume.aqc_v1.<blob>) = Fernet-encrypted{refresh_token, folder_id}. Decrypted per request, never persisted. New env varMCP_TOKEN_SEAL_KEY(derived fromFLASK_SECRET_KEYif unset; rotating it is the global panic-revoke).user_map) — the only per-user metadata kept. Enable/regenerate/disable advance or gate the epoch, giving a real per-user revoke.register_mcp_tools(mcp, require_ctx)collected fromplugin_registry— new plugins add tools without touching the core server (aligns with Plugin marketplace and registry #93).Tool surface
list_todos,list_todo_lists,create_todo,complete_todo,update_todo,get_pomodoros,get_time_summary,tag_pomodoro_to_ticket.UI
Settings → MCP Access card: enable/disable, show endpoint, mint/regenerate the one-time token.
Spec
.specify/specs/006-mcp-server-plugin/(spec + plan). Supersedes #90.Closes #111
Test plan
httpd+gunicorn+mcpall active;/returns 200/mcp→ FastMCP lists all 8 tools; missing-token auth gate enforcedrequire_ctxbranches: disabled→revoked, enabled→builds ctx, epoch-bump→superseded, no-header, tampered/api/mcp/status401 unauth;/api/mcp/enablemints token; 400 withoutfolder_id🤖 Generated with Claude Code