Skip to content

Hosted MCP server plugin: per-user, API-gateway access to plugins (#111)#113

Open
fatherlinux wants to merge 7 commits into
mainfrom
feature/111-mcp-server-plugin
Open

Hosted MCP server plugin: per-user, API-gateway access to plugins (#111)#113
fatherlinux wants to merge 7 commits into
mainfrom
feature/111-mcp-server-plugin

Conversation

@fatherlinux

Copy link
Copy Markdown
Member

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

  • Transport: FastMCP over Streamable HTTP (same stack as mcp-trentina) as a second process; Apache routes /mcp127.0.0.1:5001, everything else → Flask/Gunicorn on 5000. Single container, no new volume.
  • Auth: stateless sealed token (aqc_v1.<blob>) = Fernet-encrypted {refresh_token, folder_id}. Decrypted per request, never persisted. New env var MCP_TOKEN_SEAL_KEY (derived from FLASK_SECRET_KEY if unset; rotating it is the global panic-revoke).
  • Revocation: per-user token epoch watermark (user_map) — the only per-user metadata kept. Enable/regenerate/disable advance or gate the epoch, giving a real per-user revoke.
  • Extensibility: enabled plugins contribute tools via register_mcp_tools(mcp, require_ctx) collected from plugin_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

  • Unit tests: token seal/unseal/tamper/epoch, todos CRUD, pomodoro aggregation — 29 new, 172 total pass
  • Ruff clean
  • Container builds; httpd + gunicorn + mcp all active; / returns 200
  • Full topology: Apache /mcp → FastMCP lists all 8 tools; missing-token auth gate enforced
  • require_ctx branches: disabled→revoked, enabled→builds ctx, epoch-bump→superseded, no-header, tampered
  • Endpoints: /api/mcp/status 401 unauth; /api/mcp/enable mints token; 400 without folder_id
  • Live agent round-trip (Josui/Claude): enable MCP with real Google login, connect to the endpoint, list/create/complete a todo — pending manual verification

🤖 Generated with Claude Code

fatherlinux and others added 4 commits July 6, 2026 09:55
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>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gatehouse found 28 issues (7 critical, 8 high, 6 medium, 7 low)

Comment thread mcp_server.py
SCOPES = [
"https://www.googleapis.com/auth/drive.file",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Comment thread static/mcp.service
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread app.py
return jsonify({"error": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR


# =============================================================================

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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}"

Comment thread mcp_tokens.py
TOKEN_PREFIX = "aqc_v1."
TOKEN_VERSION = 1


Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Comment thread mcp_server.py
)
plugin_registry.register("extension", "todos", todos_plugin, todos_plugin.PLUGIN_METADATA)
plugin_registry.activate_storage("json-google-drive")
plugin_registry.activate_extension("todos")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Comment thread app.py
request_creds = get_credentials_from_request()
if not request_creds:
return jsonify({"error": "Not logged in"}), HTTPStatus.UNAUTHORIZED
email = request_creds.get("user_email")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Comment thread templates/index.html Outdated
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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Comment thread static/mcp.service
@@ -0,0 +1,16 @@
[Unit]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread user_map.py
Comment on lines +30 to +44
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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

Comment thread Containerfile
# 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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
  1. systemd unit files and init scripts go in rootfs/ directory (link)

Comment thread todos_plugin.py
Comment on lines +163 to +178
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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

Comment thread app.py
Comment on lines +1238 to +1244
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()})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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()})

Comment thread mcp_server.py
Comment on lines +63 to +83
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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)

Comment thread static/mcp.service
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Hardcoding the minor Python version /usr/bin/python3.12 in the systemd unit file is fragile and will break if the base image is updated to a different Python version (e.g., 3.13). Using the generic /usr/bin/python3 path is much more portable and robust.

ExecStart=/usr/bin/python3 /app/mcp_server.py

fatherlinux and others added 2 commits July 6, 2026 11:33
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Hosted MCP server plugin: per-user, API-gateway access to all plugins

1 participant