Skip to content
Merged
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
7 changes: 6 additions & 1 deletion frontend/src/app/main/data/workspace/mcp.cljs
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,14 @@
:description "This plugin enables interaction with the Penpot MCP server"
:allow-background true
:permissions
;; user:read added in the Moneta fork: the plugins runtime gates
;; penpot.currentUser / penpot.activeUsers on it, and MCP clients
;; routinely ask about users. Keep in sync with
;; mcp/packages/plugin/public/manifest.json.
#{"library:read" "library:write"
"comment:read" "comment:write"
"content:write" "content:read"}})
"content:write" "content:read"
"user:read"}})

(defonce interval-sub (atom nil))

Expand Down
85 changes: 85 additions & 0 deletions mcp/docs/moneta-cognito-auth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# Moneta fork — Cognito OAuth gate

This document covers the Pressingly/Moneta fork additions that put the Penpot
MCP server behind AWS Cognito / mPass SSO, mirroring `surfsense-mcp-server`
and `plane-mcp-server`. Everything lives in
`packages/server/src/moneta/`; upstream files carry three one-line hooks
(`index.ts`, `PenpotMcpServer.ts`, `PluginBridge.ts`).

## How the two legs pair

```
MCP client (Claude/Cursor) Penpot user's browser
│ │
▼ Bearer (Cognito access token) ▼ _oauth2_proxy cookie
https://design-mcp.<domain>/mcp wss://design.<domain>/mcp/ws
│ Traefik: strip-auth-headers only │ Traefik: mpass-auth ForwardAuth
▼ ▼ + X-Auth-Request-Email injected
Cognito gate (this fork) penpot-frontend nginx → :4402
│ identity = id_token email │ identity = header email
└────────────► pair by equality ◄──────────┘
(PluginBridge clientsByToken)
```

- **MCP leg.** `/mcp` is *not* behind mPass. The server is its own OAuth 2.0
authorization server (RFC 8414/9728 discovery, RFC 7591 DCR shim,
`/authorize` + `/token` proxied to Cognito with a two-hop redirect through
`{MCP_BASE_URL}/auth/callback`), so MCP clients auto-OAuth — no manual
token paste. Inbound Bearers are Cognito **access tokens**, JWKS-verified
per request.
- **Pairing identity.** A Cognito access token has no `email` and an opaque
UUID `username` for federated users, so identity comes from the **id_token**
captured at code/refresh exchange (userInfo endpoint as self-healing
fallback), precedence `email → cognito:username → access-token username` —
the same order oauth2-proxy uses for `X-Auth-Request-Email` /
`X-Auth-Request-User`. No identity → fail closed (401).
- **Plugin leg.** Unchanged for the user: the Penpot plugin connects to
`wss://design.<domain>/mcp/ws` (the URL penpot's `cfg/mcp-ws-uri` already
produces), which traverses mPass. The bridge prefers the injected
`X-Auth-Request-Email` header over the upstream `?userToken=` JWE.
- The resolved identity is exposed to the untouched upstream handlers as
`req.query.userToken`, so session bookkeeping and `PluginBridge` pairing
work exactly as upstream wrote them. Each `mcp-session-id` is additionally
pinned to the identity that created it.

In Cognito mode multi-user mode is forced on (`index.ts` hook), so the
single-user "any connected plugin" fallback can never cross users. The
upstream `/mcp/stream?userToken=<JWE>` path effectively retires in this mode
(requests without a Bearer get 401); MCP clients use
`https://design-mcp.<domain>/mcp` instead of the URL shown in Penpot's UI.

## Environment

| Variable | Purpose |
|---|---|
| `COGNITO_USER_POOL_ID` | Enables Cognito mode (its presence is the switch). |
| `COGNITO_AWS_REGION` / `AWS_REGION` | Cognito region. |
| `OIDC_CLIENT_ID` | Pre-registered Cognito app client (shared with the other MCPs). |
| `OIDC_CLIENT_SECRET` | Optional — omit for public/PKCE clients. |
| `MCP_BASE_URL` | Public URL of this server, e.g. `https://design-mcp.<domain>`. |
| `MCP_ALLOWED_ORIGINS` | CSV CORS allow-list, or `*` (dev/Inspector). |
| `MCP_ALLOWED_CLIENT_REDIRECT_URIS` | CSV allow-list for DCR redirect URIs; unset = allow all. |
| `MCP_OAUTH_STORAGE_URL` | `redis://valkey:6379/13` — encrypted OAuth state (AES-256-GCM, key HKDF-derived from `OIDC_CLIENT_SECRET` or `MCP_JWT_SIGNING_KEY`). Unset = in-memory. |
| `MCP_JWT_SIGNING_KEY` | Storage-encryption key material when there is no client secret. |
| `MCP_ENV` | `production` logs a warning when storage is in-memory. |

Valkey DB allocation: 11 = surfsense-mcp, 12 = plane-mcp, **13 = penpot-mcp**.

## One-time IdP step

Add `https://design-mcp.<domain>/auth/callback` to the Cognito app client's
allowed callback URLs (same app client as the sibling MCPs — no new client).

## Devstack

Service `penpot-mcp` in `foss-server-bundle/docker-compose.dev.yml` (profiles
`penpot-mcp` / `mcp`): Traefik router `design-mcp.<domain>` →
`penpot-mcp:4401` with `strip-auth-headers` only, healthcheck on `/healthz`.
Rebuild with `make dev.build.penpot.mcp`. Remember `PENPOT_MCP_URI` /
`PENPOT_MCP_URI_WS` in `.env` so penpot-frontend's nginx proxies `/mcp/ws` to
this server (the plugin leg).

Local smoke test without Cognito reachability: start with a fake pool id and
probe `GET /healthz`, `GET /.well-known/oauth-authorization-server`,
`POST /register`, and confirm `/mcp` answers 401 with a `WWW-Authenticate`
challenge.
2 changes: 1 addition & 1 deletion mcp/packages/plugin/public/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@
"icon": "icon.jpg",
"version": 2,
"description": "This plugin enables interaction with the Penpot MCP server",
"permissions": ["content:read", "content:write", "library:read", "library:write", "comment:read", "comment:write"]
"permissions": ["content:read", "content:write", "library:read", "library:write", "comment:read", "comment:write", "user:read"]
}
4 changes: 3 additions & 1 deletion mcp/packages/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"type": "module",
"main": "dist/index.js",
"scripts": {
"build:server": "esbuild src/index.ts --bundle --platform=node --target=node18 --format=esm --outfile=dist/index.js --external:@modelcontextprotocol/* --external:ws --external:express --external:class-transformer --external:class-validator --external:reflect-metadata --external:pino --external:pino-pretty --external:pino-loki --external:js-yaml --external:sharp",
"build:server": "esbuild src/index.ts --bundle --platform=node --target=node18 --format=esm --outfile=dist/index.js --external:@modelcontextprotocol/* --external:ws --external:express --external:class-transformer --external:class-validator --external:reflect-metadata --external:pino --external:pino-pretty --external:pino-loki --external:js-yaml --external:sharp --external:ioredis --external:jose",
"build": "pnpm run build:server && node scripts/copy-resources.js",
"build:types": "tsc --emitDeclarationOnly --outDir dist",
"start": "node dist/index.js",
Expand All @@ -28,6 +28,8 @@
"class-transformer": "^0.5.1",
"class-validator": "^0.14.3",
"express": "^5.1.0",
"ioredis": "^5.4.1",
"jose": "^5.9.6",
"js-yaml": "^4.1.1",
"penpot-mcp": "file:..",
"pino": "^9.10.0",
Expand Down
6 changes: 6 additions & 0 deletions mcp/packages/server/src/PenpotMcpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { ExportShapeTool } from "./tools/ExportShapeTool";
import { ImportImageTool } from "./tools/ImportImageTool";
import { ReplServer } from "./ReplServer";
import { ApiDocs } from "./ApiDocs";
import { installMonetaAuth, monetaAuthEnabled } from "./moneta";

/**
* Session context for request-scoped data.
Expand Down Expand Up @@ -321,6 +322,11 @@ export class PenpotMcpServer {
this.app = express();
this.app.use(express.json());

// Moneta fork: Cognito OAuth gate (no-op unless COGNITO_USER_POOL_ID is set)
if (monetaAuthEnabled()) {
await installMonetaAuth(this.app, this.logger);
}

this.setupHttpEndpoints();

return new Promise((resolve) => {
Expand Down
4 changes: 3 additions & 1 deletion mcp/packages/server/src/PluginBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { PluginTask } from "./PluginTask";
import { PluginTaskResponse, PluginTaskResult } from "@penpot/mcp-common";
import { createLogger } from "./logger";
import type { PenpotMcpServer } from "./PenpotMcpServer";
import { monetaIdentityFromUpgrade } from "./moneta";

const KEEP_ALIVE_TIME = 30000; // 30 seconds

Expand Down Expand Up @@ -43,8 +44,9 @@ export class PluginBridge {
private setupWebSocketHandlers(): void {
this.wsServer.on("connection", (ws: WebSocket, request: http.IncomingMessage) => {
// extract userToken from query parameters
// (Moneta fork: the mPass identity headers take precedence in Cognito mode)
const url = new URL(request.url!, `ws://${request.headers.host}`);
const userToken = url.searchParams.get("userToken");
const userToken = monetaIdentityFromUpgrade(request) ?? url.searchParams.get("userToken");

// require userToken if running in multi-user mode
if (this.mcpServer.isMultiUserMode() && !userToken) {
Expand Down
5 changes: 4 additions & 1 deletion mcp/packages/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { PenpotMcpServer } from "./PenpotMcpServer";
import { createLogger, logActiveTransports } from "./logger";
import { monetaAuthEnabled } from "./moneta";

/**
* Entry point for Penpot MCP Server
Expand Down Expand Up @@ -37,7 +38,9 @@ async function main(): Promise<void> {
}
}

const server = new PenpotMcpServer(multiUser);
// Moneta fork: Cognito auth implies multiple users — never route tools
// through the single-user "any connected plugin" fallback.
const server = new PenpotMcpServer(multiUser || monetaAuthEnabled());
await server.start();

// keep the process alive
Expand Down
70 changes: 70 additions & 0 deletions mcp/packages/server/src/moneta/bridge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* Moneta fork — plugin-side identity for the WebSocket bridge.
*
* In the devstack the plugin connects from the user's browser to
* wss://design.<domain>/mcp/ws, which traverses Traefik's mpass-auth
* ForwardAuth: oauth2-proxy validates the shared SSO cookie and Traefik
* injects X-Auth-Request-Email / X-Auth-Request-User into the upgrade request
* before nginx proxies it to this server. Reading those headers gives the
* plugin connection the same identity string that the Cognito gate derives
* for MCP sessions, so the two sides pair by simple equality.
*
* Precedence mirrors cognito.pickPairingIdentity exactly: a *usable* email
* first (see cognito.usablePairingEmail — in the Moneta pool this header
* carries the bare askii user id, the join key with the MCP leg's native
Comment thread
hunzlahmalik marked this conversation as resolved.
* twin identity), then X-Auth-Request-User, which oauth2-proxy fills from
* the `cognito:username` claim (OAUTH2_PROXY_USER_ID_CLAIM).
*
* Trust: external requests can never carry forged headers — Traefik's
* strip-auth-headers middleware removes inbound X-Auth-Request-* on every
* router before mpass-auth re-adds them. Inside the docker network the
* boundary is the network itself, the same model the sibling MCP servers use
* for their identity-header paths.
*
* Returns null when Cognito mode is off or no header is present, in which
* case the caller falls back to the upstream ?userToken= pairing untouched.
*/

import type * as http from "node:http";
import { createLogger } from "../logger";
import { usablePairingEmail } from "./cognito";
import { monetaAuthEnabled } from "./config";

const logger = createLogger("moneta.bridge");

function headerValue(request: http.IncomingMessage, name: string): string | null {
const raw = request.headers[name];
const value = Array.isArray(raw) ? raw[0] : raw;
const trimmed = value?.trim();
return trimmed ? trimmed : null;
}

/** Short non-reversible marker for log correlation — never the full identifier. */
function fingerprint(value: string | null): string {
return value ? `${value.slice(0, 8)}…` : "<unset>";
}

export function monetaIdentityFromUpgrade(request: http.IncomingMessage): string | null {
if (!monetaAuthEnabled()) {
return null;
}
const emailHeader = headerValue(request, "x-auth-request-email");
const userHeader = headerValue(request, "x-auth-request-user");
const identity = usablePairingEmail(emailHeader) ?? userHeader;
// Fingerprints only at info — full identifiers would leak PII into
// production logs. Raw header values are available at debug for pairing
// diagnosis (the dev overlay runs with PENPOT_MCP_LOG_LEVEL=debug).
logger.info(
"Plugin connection identity: %s (email header=%s, user header=%s)",
identity ? fingerprint(identity) : "<none — falling back to ?userToken>",
fingerprint(emailHeader),
fingerprint(userHeader)
);
logger.debug(
"Plugin connection identity (full): %s (email header=%s, user header=%s)",
identity ?? "<none>",
emailHeader ?? "<unset>",
userHeader ?? "<unset>"
);
return identity;
}
Loading
Loading