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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .env.test
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,11 @@ PLANE_TEST_MCP_URL=http://localhost:8211
# ---------------------------------------------------------------------------
# Redis / Token storage (HTTP / SSE modes)
# ---------------------------------------------------------------------------
# Without Redis, OAuth tokens are kept in memory and lost on restart.
# One of the options below is required for http/sse — the server refuses to
# start without a token store. An in-memory store is per-process, so a restart
# makes the refresh grant answer invalid_grant and every connected client
# erases its credentials; take it only for local development:
# PLANE_ALLOW_EPHEMERAL_TOKEN_STORE=true

# Option A — plain Redis (no auth)
# REDIS_HOST=localhost
Expand Down
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,8 @@ Integration tests in `tests/test_integration.py` use `FastMCP.Client` with `Stre
| `PLANE_WORKSPACE_SLUG` | stdio | Target workspace |
| `PLANE_BASE_URL` | all (default: https://api.plane.so) | Plane API URL |
| `PLANE_INTERNAL_BASE_URL` | http/sse (optional) | Internal URL for server-to-server calls |
| `REDIS_HOST` / `REDIS_PORT` | http/sse (optional) | Token storage (falls back to in-memory) |
| `REDIS_HOST` / `REDIS_PORT` | http/sse | Token storage. Without it the server refuses to start |
| `PLANE_ALLOW_EPHEMERAL_TOKEN_STORE` | http/sse (optional) | Accept an in-memory token store instead. Per-process, so a restart makes the refresh grant answer `invalid_grant` and every client erases its credentials — local dev only |
| `PLANE_OAUTH_PROVIDER_*` | http/sse OAuth | OAuth client credentials and base URL |
| `PLANE_OAUTH_ALLOWED_REDIRECT_URIS` | http/sse OAuth (optional) | Comma-separated redirect URI patterns appended to the built-in allowlist (onboard clients without a release) |
| `LOG_USER_INFO` | all (optional, default: false) | When `true`, include user info (PII such as display name) in logs alongside the opaque user id |
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,8 @@ Self-hosting the server itself:
| Variable | Purpose |
|---|---|
| `PLANE_INTERNAL_BASE_URL` | Internal URL for server-to-server calls, preferred over `PLANE_BASE_URL` |
| `REDIS_HOST` / `REDIS_PORT` | OAuth token storage; falls back to in-memory |
| `REDIS_HOST` / `REDIS_PORT` | OAuth token storage. Required for http/sse — without it the server refuses to start |
| `PLANE_ALLOW_EPHEMERAL_TOKEN_STORE` | Accept an in-memory token store instead (local dev only — clients are logged out on every restart) |
| `PLANE_OAUTH_PROVIDER_*` | OAuth client credentials and base URL |
| `MCP_PATH_PREFIX` | Path prefix for the HTTP routes, when mounted behind a proxy — `/plane` serves `/plane/http/mcp` |

Expand Down
11 changes: 9 additions & 2 deletions plane_mcp/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from starlette.routing import Mount

from plane_mcp.server import get_header_mcp, get_oauth_mcp, get_stdio_mcp
from plane_mcp.storage import build_token_store

LOG_USER_INFO: bool = os.getenv("LOG_USER_INFO", "").lower() == "true"

Expand Down Expand Up @@ -145,11 +146,17 @@ def main() -> None:
if server_mode == ServerMode.HTTP:
prefix = os.getenv("MCP_PATH_PREFIX") or ""

oauth_mcp = get_oauth_mcp(prefix + "/http")
# One store for both OAuth mounts. They serve the same clients from the
# same process, so a client that registers on /http must resolve on /sse
# — two stores would only agree by way of a shared Redis, and would not
# agree at all on the in-memory one.
token_store = build_token_store()

oauth_mcp = get_oauth_mcp(prefix + "/http", client_storage=token_store)
oauth_app = oauth_mcp.http_app(stateless_http=True)
header_app = get_header_mcp().http_app(stateless_http=True)

sse_mcp = get_oauth_mcp(prefix)
sse_mcp = get_oauth_mcp(prefix, client_storage=token_store)
sse_app = sse_mcp.http_app(transport="sse")

# mcp_path is appended to the auth provider's base_url to form the
Expand Down
13 changes: 10 additions & 3 deletions plane_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import os

from fastmcp import FastMCP
from key_value.aio.protocols import AsyncKeyValue
from mcp.types import Icon

from plane_mcp.auth import PlaneHeaderAuthProvider, PlaneOAuthProvider
Expand Down Expand Up @@ -60,8 +61,14 @@ def _configured(mcp: FastMCP) -> FastMCP:
return mcp


def get_oauth_mcp(base_path: str = "/") -> FastMCP:
"""Build the FastMCP instance for the OAuth HTTP / SSE transports."""
def get_oauth_mcp(base_path: str = "/", client_storage: AsyncKeyValue | None = None) -> FastMCP:
"""Build the FastMCP instance for the OAuth HTTP / SSE transports.

``client_storage`` is the OAuth state store. The HTTP transport builds two
of these instances (``/http`` and ``/sse``) and they must share one store,
so a caller that builds more than one passes the store in; omitting it
builds a private one, which is only right for a single instance.
"""
oauth_mcp = FastMCP(
"Plane MCP Server",
instructions=SERVER_INSTRUCTIONS,
Expand All @@ -74,7 +81,7 @@ def get_oauth_mcp(base_path: str = "/") -> FastMCP:
plane_base_url=os.getenv("PLANE_BASE_URL", ""),
plane_internal_base_url=os.getenv("PLANE_INTERNAL_BASE_URL", ""),
enable_cimd=os.getenv("PLANE_OAUTH_PROVIDER_ENABLE_CIMD", "false").lower() == "true",
client_storage=build_token_store(),
client_storage=client_storage if client_storage is not None else build_token_store(),
required_scopes=["read", "write"],
allowed_client_redirect_uris=get_allowed_client_redirect_uris(),
),
Expand Down
44 changes: 40 additions & 4 deletions plane_mcp/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,17 @@
(``AWS_CONTAINER_CREDENTIALS_FULL_URI``) + host/port → Redis with a rotating
AUTH token from AWS Secrets Manager.
3. ``REDIS_HOST`` + ``REDIS_PORT`` → plain Redis (no auth).
4. None of the above → in-memory store (dev only; tokens lost on restart).
4. None of the above → refuses to start, unless
``PLANE_ALLOW_EPHEMERAL_TOKEN_STORE`` opts in to an in-memory store.

Misconfigurations raise ``RuntimeError`` at startup. Reachability is verified
eagerly with a synchronous PING.

The in-memory store is not a safe default: it is per-process, so a restart or a
second replica loses the OAuth state behind tokens clients still hold, and the
refresh grant then answers ``invalid_grant`` — which makes clients erase their
credentials. That reads to the user as being logged out for no reason, so it is
opt-in rather than a fallback.
"""

from __future__ import annotations
Expand All @@ -26,6 +33,15 @@
logger = get_logger(__name__)


# Opt-in for the in-memory store. Named for what it costs, not what it enables.
ALLOW_MEMORY_STORE_ENV = "PLANE_ALLOW_EPHEMERAL_TOKEN_STORE"


def _memory_store_allowed() -> bool:
"""True when the caller has explicitly accepted a store lost on restart."""
return os.getenv(ALLOW_MEMORY_STORE_ENV, "").strip().lower() in {"1", "true", "yes", "on"}


def _has_aws_credentials() -> bool:
"""True when IRSA or EKS Pod Identity env vars are set."""
return bool(os.getenv("AWS_ROLE_ARN") or os.getenv("AWS_CONTAINER_CREDENTIALS_FULL_URI"))
Expand Down Expand Up @@ -98,7 +114,7 @@ def build_token_store() -> Any:
# static-password deployments. Set REDIS_SSL=true for TLS-fronted Redis.
use_ssl = _redis_ssl_enabled(default=False)
_ping_redis(redis_host, int(redis_port), password=password, ssl=use_ssl)
store = RedisStore(host=redis_host, port=int(redis_port), password=password)
store = RedisStore(host=redis_host, port=int(redis_port), password=password, ssl=use_ssl)
logger.info(
"Token store: Redis (auth=password, host=%s, port=%s, ssl=%s)",
redis_host,
Expand Down Expand Up @@ -161,6 +177,26 @@ def build_token_store() -> Any:
logger.info("Token store: Redis (auth=none, host=%s, port=%s)", redis_host, redis_port)
return store

# 4. In-memory fallback
logger.warning("Token store: in-memory (tokens lost on restart). Set REDIS_HOST and REDIS_PORT for production.")
# 4. In-memory — opt-in only.
#
# This store is per-process, so every restart drops the OAuth state behind
# tokens clients still hold: the JTI mapping vanishes and the refresh grant
# answers invalid_grant, which is one of the three codes that make a client
# erase its credentials. The user sees "logged out", with no way to tell it
# from a real revocation. A warning is too quiet for that, so http/sse
# refuse to start rather than serve auth that dies at the next deploy.
if not _memory_store_allowed():
raise RuntimeError(
"No token store configured. The HTTP/SSE transports keep OAuth state "
"(client registrations, token mappings) in this store, and an in-memory "
"one is lost on every restart — clients are silently logged out. "
"Set REDIS_HOST and REDIS_PORT, or set "
f"{ALLOW_MEMORY_STORE_ENV}=true to accept that for local development."
)

logger.warning(
"Token store: in-memory (%s=true). Tokens are lost on restart and are not "
"shared between processes — never use this in production.",
ALLOW_MEMORY_STORE_ENV,
)
return MemoryStore()
42 changes: 40 additions & 2 deletions tests/test_aws_secrets.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@

from plane_mcp import aws_secrets, storage
from plane_mcp.aws_secrets import ElastiCacheCredentialProvider, get_secret
from plane_mcp.storage import build_token_store
from plane_mcp.storage import ALLOW_MEMORY_STORE_ENV, build_token_store

ARN = "arn:aws:secretsmanager:us-east-1:123456789012:secret:test"
REGION = "us-east-1"
Expand Down Expand Up @@ -254,10 +254,48 @@ def test_credential_provider_missing_key_raises(monkeypatch):
# ---------------------------------------------------------------------------


def test_build_token_store_no_env_returns_memory_store():
def test_build_token_store_no_env_refuses_to_start(monkeypatch):
"""An unconfigured store is a silent logout machine, so it must not be a default.

In-memory state dies with the process; the refresh grant then answers
invalid_grant and every connected client erases its credentials.
"""
monkeypatch.delenv(ALLOW_MEMORY_STORE_ENV, raising=False)
with pytest.raises(RuntimeError, match="No token store configured"):
build_token_store()


def test_build_token_store_memory_requires_explicit_opt_in(monkeypatch):
monkeypatch.setenv(ALLOW_MEMORY_STORE_ENV, "true")
assert isinstance(build_token_store(), MemoryStore)


@pytest.mark.parametrize("value", ["false", "0", "no", "", " "])
def test_build_token_store_memory_opt_in_rejects_non_truthy(monkeypatch, value):
monkeypatch.setenv(ALLOW_MEMORY_STORE_ENV, value)
with pytest.raises(RuntimeError, match="No token store configured"):
build_token_store()


def test_build_token_store_password_branch_propagates_tls_to_the_store(monkeypatch, no_ping):
"""REDIS_SSL reached the PING and the log line but not the store itself.

The startup PING then succeeded over TLS while every subsequent store
operation dialled plaintext — auth that fails only after boot looks fine.
"""
monkeypatch.setenv("REDIS_HOST", "localhost")
monkeypatch.setenv("REDIS_PORT", "6379")
monkeypatch.setenv("REDIS_PASSWORD", "pw")
monkeypatch.setenv("REDIS_SSL", "true")
_forbid_boto3_secretsmanager(monkeypatch)

store = build_token_store()

assert store._client.connection_pool.connection_class.__name__ == "SSLConnection", (
"REDIS_SSL=true must reach RedisStore, not just the startup PING"
)


def test_build_token_store_host_port_only_returns_redis(monkeypatch, no_ping):
monkeypatch.setenv("REDIS_HOST", "localhost")
monkeypatch.setenv("REDIS_PORT", "6379")
Expand Down