From 7b1e09199426be54b1f0a181a12274f0191094a8 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 11 Sep 2026 10:36:04 -0700 Subject: [PATCH 1/7] Add OpenPRD for remote MCP session gateway --- prd/0014-remote-mcp-session-gateway.md | 142 +++++++++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 prd/0014-remote-mcp-session-gateway.md diff --git a/prd/0014-remote-mcp-session-gateway.md b/prd/0014-remote-mcp-session-gateway.md new file mode 100644 index 0000000..50e8e31 --- /dev/null +++ b/prd/0014-remote-mcp-session-gateway.md @@ -0,0 +1,142 @@ +--- +openprd: "0.3" +id: "0014" +title: "Expose live Moshcode sessions over remote MCP" +status: Draft +authors: + - ralyodio +created: 2026-09-11 +updated: 2026-09-11 +repo: https://github.com/moshcoder/moshcode +discussion: +implementation: feat/0014-remote-mcp-session-gateway +tags: + - mcp + - oauth + - sessions + - agents +supersedes: +superseded-by: +--- + +## Problem + +Moshcode already provides a live browser mirror of a running CLI session: the CLI registers itself with app.moshcode.sh, streams terminal output, and drains a server-side command queue. That makes remote human control possible, but external AI clients such as ChatGPT, Codex, Claude, and other MCP-capable agents cannot securely attach to that same session through a standard protocol. + +Without a standard AI-facing endpoint, every client would need a custom integration or would have to drive the browser UI. Sharing a permanent Moshcode API key or SSH credential with an AI client would also grant far more authority than is necessary and would make revocation, session scoping, and auditability difficult. + +The product opportunity is to make Moshcode the persistent development environment and MCP the interoperable control plane: connect an AI once, authorize only the session capabilities it needs, then let it observe and operate the same live Moshcode session the user can see in the browser. + +## Goals + +- Let standards-compatible remote MCP clients connect to a public Moshcode MCP endpoint. +- Reuse the existing live CLI session mirror and command queue rather than inventing a second shell/session transport. +- Let a user explicitly authorize an MCP client with OAuth Authorization Code + PKCE. +- Issue short-lived access tokens and rotating refresh tokens instead of exposing Moshcode CLI API keys. +- Support least-privilege authorization with separate read/control scopes and optional binding to one Moshcode session. +- Let an authorized client list sessions, read sequenced terminal output, queue commands, and send already-supported navigation key presses. +- Support the current MCP `2026-07-28` stateless lifecycle while remaining usable by handshake-era 2025 MCP clients. +- Keep the MCP server stateless at the protocol layer; persistent state is represented by the existing explicit Moshcode `session_id`. + +## Non-Goals + +- Replacing the existing Moshcode CLI, browser session mirror, or CLI authentication mechanism. +- Giving MCP clients direct SSH credentials, sudo access, host secrets, environment variables, or arbitrary infrastructure-admin APIs. +- Creating a second PTY/session broker separate from the current Moshcode session mirror. +- Exposing filesystem and Git APIs in v1; an authorized client can operate the existing Moshcode prompt, and dedicated structured tools can be added later. +- Automatically approving high-impact commands on behalf of the user or bypassing client-side tool confirmation policies. +- Supporting legacy HTTP+SSE as a primary transport; HTTP POST is the supported remote MCP transport. +- Enabling Client ID Metadata Documents (CIMD) until Moshcode has a hardened metadata fetcher that cannot be abused for SSRF. Dynamic Client Registration remains the compatibility path for v1. +- Refactoring the PWA database or session subsystem as part of this feature. + +## Users + +- Moshcode users who want ChatGPT, Codex, Claude, or another MCP-capable agent to work inside a live Moshcode development session. +- Developers who use multiple AI clients and want one interoperable authorization/control surface rather than client-specific integrations. +- Teams that need an auditable, revocable alternative to sharing SSH credentials or permanent API keys with AI tooling. +- Moshcode itself, as a platform: MCP turns an existing remote session mirror into a reusable agent runtime surface. + +## Requirements + +- R1 [P0] Expose a remote MCP endpoint at `${PUBLIC_ORIGIN}/mcp`. +- R2 [P0] Require bearer authorization for every MCP tool/discovery request other than OAuth metadata and registration. +- R3 [P0] Publish OAuth Authorization Server Metadata and OAuth Protected Resource Metadata using `.well-known` endpoints. +- R4 [P0] Support OAuth Dynamic Client Registration for public clients with exact redirect URI validation and no client secret. +- R5 [P0] Support Authorization Code with PKCE S256. Authorization codes MUST be single-use, short-lived, and stored only as hashes. +- R6 [P0] Issue short-lived access tokens and rotating refresh tokens. Tokens MUST be stored only as hashes and bound to the Moshcode MCP resource. +- R7 [P0] Support `sessions:read` and `sessions:control` scopes. Control implies read. +- R8 [P0] Present a logged-in consent screen that shows requested scopes and lets the user authorize all sessions or bind the grant to one owned Moshcode session. +- R9 [P0] Enforce session binding on every session tool server-side, not only in tool descriptions or UI. +- R10 [P0] Implement `moshcode_sessions_list` as a read-only MCP tool. +- R11 [P0] Implement `moshcode_session_read` using the existing monotonically sequenced `session_output` rows and an `after_seq` cursor. +- R12 [P0] Implement `moshcode_session_send` using the existing `session_commands` queue. Multi-line text MUST preserve order and inherit the browser mirror's command count/length limits. +- R13 [P0] Implement `moshcode_session_key` only for the navigation keys already declared by the CLI's `keys` feature. +- R14 [P0] Advertise MCP tool annotations so hosts can distinguish read-only tools from session-modifying tools. +- R15 [P0] Support MCP protocol `2026-07-28`, including `server/discover`, stateless per-request operation, `resultType`, private cache hints, and server identity metadata. +- R16 [P0] Support 2025 handshake-era clients for `initialize`, `notifications/initialized`, `tools/list`, `tools/call`, and `ping`. +- R17 [P0] Return a standards-compatible `WWW-Authenticate` challenge when an MCP access token is missing/invalid and an insufficient-scope challenge when a control tool needs more scope. +- R18 [P0] Keep machine OAuth/MCP endpoints outside browser CSRF enforcement while keeping `/oauth/authorize` behind the existing login + CSRF protections. +- R19 [P0] Add database migrations for OAuth clients, authorization codes, access tokens, and refresh tokens with user/client/session foreign keys. +- R20 [P0] Add integration tests for bearer challenges, PKCE/token issuance, scoped tool discovery, session-bound access, and command queue insertion. +- R21 [P1] Add a revocation UI/API showing connected MCP clients, granted scopes, session binding, last use, and a one-click revoke action. +- R22 [P1] Add hardened Client ID Metadata Document support and advertise `client_id_metadata_document_supported: true`. +- R23 [P1] Move session command wake/fan-out to a shared or cross-instance signal so MCP-queued commands wake an already-parked CLI long-poll immediately on every deployment topology. +- R24 [P1] Add an explicit interrupt/control capability once the CLI advertises and safely decodes Ctrl-C as a negotiated session feature. +- R25 [P2] Add structured Git/filesystem tools only when they can reuse Moshcode's permission model and provide a narrower authority than raw terminal commands. + +## UX Notes + +The connection flow should feel like connecting any other account-backed tool: + +1. The MCP client discovers `https://app.moshcode.sh/mcp`, receives the protected-resource challenge, and discovers the Moshcode authorization server. +2. The client dynamically registers its exact redirect URI and starts an OAuth authorization-code + PKCE flow. +3. Moshcode requires the user to be signed in, then shows the client name, requested permissions, the MCP resource, and session-access choices. +4. The user can authorize all sessions or one named live/recent session. One-session authorization should be the recommended least-privilege option when the user knows which workspace the agent needs. +5. After approval, the client receives the authorization code and exchanges it for an access + refresh token pair. +6. The agent calls `moshcode_sessions_list`, then `moshcode_session_read`, then a control tool only when needed. +7. Incremental reads use `after_seq`/`next_seq`; agents should not repeatedly request the full scrollback. +8. Sending a command means exactly what it does in the existing web mirror: text is queued into the live Moshcode prompt. The tool description must not pretend it is a sandbox. +9. If a token is bound to one session, other session ids should behave as inaccessible even if they belong to the same user. +10. Write/control tools should be surfaced to clients with non-read-only annotations so host confirmation policies can protect consequential operations. + +The server should refer to the public endpoint using `PUBLIC_ORIGIN`; production is expected to expose the PWA at `https://app.moshcode.sh`. + +## Tech Stack + +- Existing `apps/pwa` Express application on Node.js 20+. +- Existing libSQL database helper and numbered SQL migration system. +- Existing cookie login/session + CSRF middleware for the browser consent flow. +- Existing `cli_sessions`, `session_output`, and `session_commands` tables as the runtime/session substrate. +- OAuth Authorization Code + PKCE S256 implemented with Node's built-in crypto helpers; no permanent MCP client secrets. +- OAuth Dynamic Client Registration in v1 for cross-client compatibility. +- Stateless HTTP Model Context Protocol implementation supporting current `2026-07-28` plus a 2025 compatibility path. +- No new runtime dependency is required for the v1 implementation; the wire surface is intentionally small (`server/discover`, initialize compatibility, tools list/call, ping). +- Railway remains the production application host; no separate MCP service is required for v1. + +## Monetization + +_None for v1._ + +Remote MCP session access is a platform capability that makes Moshcode more useful and more interoperable. Future paid plans may place limits on concurrent remote sessions, organization policy, audit retention, or advanced structured tools, but authorization or baseline interoperability must not be artificially weakened to force an upgrade. + +## Success Metrics + +- A standards-compatible remote MCP client can complete discovery, OAuth, tool scan, session listing, and an incremental session read without manual API-key copying. +- A control-scoped client can queue a command that the existing Moshcode CLI consumes without a new CLI transport. +- A read-only token cannot discover or invoke control tools. +- A token bound to one session cannot list/read/control another session. +- Authorization-code replay and refresh-token replay do not mint additional valid credentials. +- Access tokens expire automatically and refresh tokens rotate. +- The existing `/sessions` browser mirror and CLI auth flows continue passing their tests unchanged. +- At least one external MCP host can connect end-to-end to the deployed endpoint after merge. +- No SSH credential, Moshcode CLI API key, or server secret is revealed to the MCP client. + +## Risks & Open Questions + +- **MCP host product limits:** ChatGPT plan/workspace support for write-capable custom MCP apps is controlled by OpenAI and can differ from Codex/API/other MCP hosts. The server should remain standards-based rather than special-casing one host. +- **DCR lifecycle:** MCP 2026-07-28 deprecates Dynamic Client Registration in favor of CIMD, but DCR remains a compatibility path. Hardened CIMD support is P1 because naively fetching arbitrary client metadata URLs creates an SSRF risk. +- **Command delivery wake-up:** the current session mirror wakes long-polls through an in-process map. A command inserted by another app instance—or by this v1 MCP route without access to that private map—can wait until the existing long-poll timeout. R23 should move wake signaling behind a shared session-control primitive or cross-instance signal. +- **Raw terminal authority:** `moshcode_session_send` can run whatever the Moshcode prompt itself permits. OAuth/session scoping reduces credential blast radius but does not make dangerous shell commands safe; MCP hosts should continue applying confirmation policies. +- **Revocation UX:** v1 tokens can expire/rotate but users need a first-class connected-clients page before this should be marketed as an organization-management feature. +- **Audit:** the existing command queue records queued commands and timestamps, but a dedicated OAuth-client/audit attribution field may be desirable so a human can distinguish browser commands from individual MCP clients. +- **Database direction:** this PRD intentionally uses the repository's current persistence layer and does not bundle a database migration/replatforming project into MCP. From baa0e1f4b271f1b8db8533477efae390ac1244c8 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 11 Sep 2026 10:36:13 -0700 Subject: [PATCH 2/7] Add MCP OAuth persistence --- apps/pwa/src/migrations/020_mcp_oauth.sql | 43 +++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 apps/pwa/src/migrations/020_mcp_oauth.sql diff --git a/apps/pwa/src/migrations/020_mcp_oauth.sql b/apps/pwa/src/migrations/020_mcp_oauth.sql new file mode 100644 index 0000000..612f1b9 --- /dev/null +++ b/apps/pwa/src/migrations/020_mcp_oauth.sql @@ -0,0 +1,43 @@ +-- Remote MCP OAuth clients and credentials. +-- +-- Access/refresh tokens and authorization codes are stored only as SHA-256 +-- hashes. A database read is therefore not enough to impersonate a connected +-- MCP client. + +CREATE TABLE IF NOT EXISTS mcp_oauth_clients ( + client_id TEXT PRIMARY KEY, + client_name TEXT NOT NULL, + redirect_uris TEXT NOT NULL, + application_type TEXT NOT NULL DEFAULT 'web', + client_uri TEXT, + created_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS mcp_oauth_codes ( + code_hash TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + client_id TEXT NOT NULL REFERENCES mcp_oauth_clients(client_id) ON DELETE CASCADE, + redirect_uri TEXT NOT NULL, + scope TEXT NOT NULL, + resource TEXT NOT NULL, + session_id TEXT REFERENCES cli_sessions(id) ON DELETE CASCADE, + code_challenge TEXT NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_mcp_oauth_codes_expiry ON mcp_oauth_codes(expires_at); + +CREATE TABLE IF NOT EXISTS mcp_oauth_tokens ( + token_hash TEXT PRIMARY KEY, + token_type TEXT NOT NULL, -- access | refresh + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + client_id TEXT NOT NULL REFERENCES mcp_oauth_clients(client_id) ON DELETE CASCADE, + scope TEXT NOT NULL, + resource TEXT NOT NULL, + session_id TEXT REFERENCES cli_sessions(id) ON DELETE CASCADE, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + revoked_at INTEGER +); +CREATE INDEX IF NOT EXISTS idx_mcp_oauth_tokens_user ON mcp_oauth_tokens(user_id, expires_at); +CREATE INDEX IF NOT EXISTS idx_mcp_oauth_tokens_expiry ON mcp_oauth_tokens(expires_at); From 6876d9576cc9fde0a99daa802c6dac6833d74a07 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 11 Sep 2026 10:37:31 -0700 Subject: [PATCH 3/7] Add OAuth security layer for remote MCP --- apps/pwa/src/lib/mcp-auth.mjs | 251 ++++++++++++++++++++++++++++++++++ 1 file changed, 251 insertions(+) create mode 100644 apps/pwa/src/lib/mcp-auth.mjs diff --git a/apps/pwa/src/lib/mcp-auth.mjs b/apps/pwa/src/lib/mcp-auth.mjs new file mode 100644 index 0000000..bb46f1d --- /dev/null +++ b/apps/pwa/src/lib/mcp-auth.mjs @@ -0,0 +1,251 @@ +// OAuth 2.1-style bearer tokens for the remote Moshcode MCP resource. +// +// This deliberately keeps OAuth credentials separate from the CLI's long-lived +// API keys. MCP clients get short-lived, scoped access tokens plus rotating +// refresh tokens, and can be pinned to one live Moshcode session. +import { all, get, run } from "../db.mjs"; +import { config } from "../config.mjs"; +import { id, sha256, token } from "./crypto.mjs"; + +export const MCP_RESOURCE = `${config.origin}/mcp`; +export const MCP_SCOPES = ["sessions:read", "sessions:control"]; +export const ACCESS_TTL_MS = 4 * 60 * 60 * 1000; +export const REFRESH_TTL_MS = 30 * 24 * 60 * 60 * 1000; +export const CODE_TTL_MS = 5 * 60 * 1000; + +const LOOPBACK = new Set(["127.0.0.1", "::1", "[::1]", "localhost"]); + +export function normalizeScopes(value, { fallback = ["sessions:read"] } = {}) { + const requested = String(value ?? "").trim() + ? String(value).trim().split(/\s+/) + : [...fallback]; + const set = new Set(requested.filter((scope) => MCP_SCOPES.includes(scope))); + // Controlling a session without being able to identify/read it is not useful, + // so make the implication explicit rather than returning a half-working token. + if (set.has("sessions:control")) set.add("sessions:read"); + return MCP_SCOPES.filter((scope) => set.has(scope)); +} + +export const hasScope = (auth, scope) => + new Set(String(auth?.scope || "").split(/\s+/).filter(Boolean)).has(scope); + +export function validRedirectUri(raw, applicationType = "web") { + try { + const url = new URL(String(raw || "")); + if (url.hash || url.username || url.password) return false; + if (url.protocol === "https:") return true; + // Native clients are allowed to bounce through an ephemeral local listener. + return applicationType === "native" && url.protocol === "http:" && LOOPBACK.has(url.hostname); + } catch { + return false; + } +} + +export async function registerOAuthClient(metadata = {}) { + const applicationType = metadata.application_type === "native" ? "native" : "web"; + const redirectUris = Array.isArray(metadata.redirect_uris) + ? [...new Set(metadata.redirect_uris.map(String))].slice(0, 20) + : []; + if (!redirectUris.length || redirectUris.some((uri) => !validRedirectUri(uri, applicationType))) { + throw new Error("invalid redirect_uris"); + } + const clientId = `mcp_${id()}`; + const row = { + client_id: clientId, + client_name: String(metadata.client_name || "MCP client").slice(0, 120), + redirect_uris: JSON.stringify(redirectUris), + application_type: applicationType, + client_uri: metadata.client_uri ? String(metadata.client_uri).slice(0, 500) : null, + created_at: Date.now(), + }; + await run( + `INSERT INTO mcp_oauth_clients + (client_id,client_name,redirect_uris,application_type,client_uri,created_at) + VALUES (?,?,?,?,?,?)`, + [row.client_id, row.client_name, row.redirect_uris, row.application_type, row.client_uri, row.created_at] + ); + return { ...row, redirect_uris: redirectUris }; +} + +export async function oauthClient(clientId) { + if (!clientId) return null; + const row = await get(`SELECT * FROM mcp_oauth_clients WHERE client_id = ?`, [String(clientId)]); + if (!row) return null; + try { + row.redirect_uris = JSON.parse(row.redirect_uris || "[]"); + } catch { + row.redirect_uris = []; + } + return row; +} + +export async function validateAuthorizationRequest(params = {}) { + if (params.response_type !== "code") throw new Error("unsupported response_type"); + const client = await oauthClient(params.client_id); + if (!client) throw new Error("unknown client_id"); + const redirectUri = String(params.redirect_uri || ""); + if (!client.redirect_uris.includes(redirectUri)) throw new Error("redirect_uri mismatch"); + if (params.code_challenge_method !== "S256") throw new Error("PKCE S256 is required"); + const challenge = String(params.code_challenge || ""); + if (!/^[A-Za-z0-9_-]{43,128}$/.test(challenge)) throw new Error("invalid code_challenge"); + const resource = String(params.resource || MCP_RESOURCE); + if (resource !== MCP_RESOURCE) throw new Error("invalid resource"); + const rawScopes = String(params.scope || "").trim().split(/\s+/).filter(Boolean); + const unknownScopes = rawScopes.filter((scope) => !MCP_SCOPES.includes(scope)); + if (unknownScopes.length) throw new Error(`unsupported scope: ${unknownScopes.join(" ")}`); + const scopes = normalizeScopes(params.scope); + if (!scopes.length) throw new Error("no supported scopes requested"); + return { + client, + clientId: client.client_id, + redirectUri, + resource, + scopes, + scope: scopes.join(" "), + state: params.state == null ? "" : String(params.state), + codeChallenge: challenge, + }; +} + +export async function createAuthorizationCode({ + userId, + clientId, + redirectUri, + scope, + resource = MCP_RESOURCE, + sessionId = null, + codeChallenge, +}) { + const raw = `mcc_${token(32)}`; + const now = Date.now(); + await run( + `INSERT INTO mcp_oauth_codes + (code_hash,user_id,client_id,redirect_uri,scope,resource,session_id,code_challenge,created_at,expires_at) + VALUES (?,?,?,?,?,?,?,?,?,?)`, + [sha256(raw), userId, clientId, redirectUri, scope, resource, sessionId, codeChallenge, now, now + CODE_TTL_MS] + ); + return raw; +} + +export function pkceChallenge(verifier) { + return Buffer.from(sha256(String(verifier || "")), "hex").toString("base64url"); +} + +export async function exchangeAuthorizationCode({ + code, + clientId, + redirectUri, + verifier, + resource = MCP_RESOURCE, +}) { + const hash = sha256(String(code || "")); + const row = await get( + `SELECT * FROM mcp_oauth_codes WHERE code_hash = ? AND expires_at > ?`, + [hash, Date.now()] + ); + if (!row) throw new Error("invalid or expired authorization code"); + if (row.client_id !== String(clientId || "")) throw new Error("client_id mismatch"); + if (row.redirect_uri !== String(redirectUri || "")) throw new Error("redirect_uri mismatch"); + if (row.resource !== String(resource || MCP_RESOURCE)) throw new Error("resource mismatch"); + const v = String(verifier || ""); + if (!/^[A-Za-z0-9._~-]{43,128}$/.test(v) || pkceChallenge(v) !== row.code_challenge) { + throw new Error("PKCE verification failed"); + } + + // The delete is the replay lock. Exactly one concurrent exchange can consume + // this code; everyone else loses the race and mints nothing. + const consumed = await run(`DELETE FROM mcp_oauth_codes WHERE code_hash = ?`, [hash]); + if (!consumed.rowsAffected) throw new Error("authorization code already used"); + return issueTokenPair(row); +} + +async function issueTokenPair(source) { + const now = Date.now(); + const access = `mca_${token(32)}`; + const refresh = `mcr_${token(40)}`; + await run( + `INSERT INTO mcp_oauth_tokens + (token_hash,token_type,user_id,client_id,scope,resource,session_id,created_at,expires_at,revoked_at) + VALUES (?,?,?,?,?,?,?,?,?,NULL)`, + [sha256(access), "access", source.user_id, source.client_id, source.scope, source.resource, + source.session_id || null, now, now + ACCESS_TTL_MS] + ); + await run( + `INSERT INTO mcp_oauth_tokens + (token_hash,token_type,user_id,client_id,scope,resource,session_id,created_at,expires_at,revoked_at) + VALUES (?,?,?,?,?,?,?,?,?,NULL)`, + [sha256(refresh), "refresh", source.user_id, source.client_id, source.scope, source.resource, + source.session_id || null, now, now + REFRESH_TTL_MS] + ); + return { + access_token: access, + token_type: "Bearer", + expires_in: Math.floor(ACCESS_TTL_MS / 1000), + refresh_token: refresh, + scope: source.scope, + }; +} + +export async function rotateRefreshToken({ refreshToken, clientId }) { + const hash = sha256(String(refreshToken || "")); + const row = await get( + `SELECT * FROM mcp_oauth_tokens + WHERE token_hash=? AND token_type='refresh' AND revoked_at IS NULL AND expires_at > ?`, + [hash, Date.now()] + ); + if (!row || row.client_id !== String(clientId || "")) throw new Error("invalid refresh_token"); + const revoked = await run( + `UPDATE mcp_oauth_tokens SET revoked_at=? WHERE token_hash=? AND revoked_at IS NULL`, + [Date.now(), hash] + ); + if (!revoked.rowsAffected) throw new Error("refresh_token already used"); + return issueTokenPair(row); +} + +export function bearerToken(req) { + const header = String(req.get?.("authorization") || req.headers?.authorization || ""); + const match = /^Bearer\s+(.+)$/i.exec(header); + return match ? match[1].trim() : null; +} + +export async function accessForToken(raw) { + if (!raw) return null; + return get( + `SELECT * FROM mcp_oauth_tokens + WHERE token_hash=? AND token_type='access' AND revoked_at IS NULL + AND expires_at > ? AND resource = ?`, + [sha256(raw), Date.now(), MCP_RESOURCE] + ); +} + +export async function requireMcpAccess(req, res, next) { + const auth = await accessForToken(bearerToken(req)); + if (!auth) { + const metadata = `${config.origin}/.well-known/oauth-protected-resource/mcp`; + res.set("WWW-Authenticate", `Bearer resource_metadata="${metadata}"`); + return res.status(401).json({ error: "invalid_token" }); + } + req.mcpAuth = auth; + next(); +} + +export function scopeChallenge(res, scope) { + const metadata = `${config.origin}/.well-known/oauth-protected-resource/mcp`; + res.set( + "WWW-Authenticate", + `Bearer resource_metadata="${metadata}", error="insufficient_scope", scope="${scope}"` + ); +} + +export async function sessionsForAuthorization(userId) { + return all( + `SELECT id,name,host,cwd,engine,status,last_seen_at + FROM cli_sessions WHERE user_id=? ORDER BY last_seen_at DESC LIMIT 50`, + [userId] + ); +} + +export async function userOwnsSession(userId, sessionId) { + if (!sessionId) return true; + return Boolean(await get(`SELECT id FROM cli_sessions WHERE id=? AND user_id=?`, [sessionId, userId])); +} From 02f439528155b7cf9f6c55373f30b73a16560e3c Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 11 Sep 2026 10:37:54 -0700 Subject: [PATCH 4/7] Add MCP OAuth discovery and consent routes --- apps/pwa/src/routes/mcp-oauth.mjs | 232 ++++++++++++++++++++++++++++++ 1 file changed, 232 insertions(+) create mode 100644 apps/pwa/src/routes/mcp-oauth.mjs diff --git a/apps/pwa/src/routes/mcp-oauth.mjs b/apps/pwa/src/routes/mcp-oauth.mjs new file mode 100644 index 0000000..d6ed40a --- /dev/null +++ b/apps/pwa/src/routes/mcp-oauth.mjs @@ -0,0 +1,232 @@ +// OAuth endpoints used by remote MCP clients. +// +// Machine routes are mounted before the browser CSRF guard; the authorization +// page is mounted after it, so user approval is protected by the same cookie + +// double-submit CSRF discipline as the rest of the PWA. +import { Router } from "express"; +import { config } from "../config.mjs"; +import { page, esc } from "../lib/html.mjs"; +import { csrfInput, requireAuth } from "../lib/session.mjs"; +import { + MCP_RESOURCE, + MCP_SCOPES, + createAuthorizationCode, + exchangeAuthorizationCode, + normalizeScopes, + registerOAuthClient, + rotateRefreshToken, + sessionsForAuthorization, + userOwnsSession, + validateAuthorizationRequest, +} from "../lib/mcp-auth.mjs"; + +export const mcpOAuthMachineRouter = Router(); +export const mcpOAuthBrowserRouter = Router(); + +const AS_METADATA = () => ({ + issuer: config.origin, + authorization_endpoint: `${config.origin}/oauth/authorize`, + token_endpoint: `${config.origin}/oauth/token`, + registration_endpoint: `${config.origin}/oauth/register`, + response_types_supported: ["code"], + response_modes_supported: ["query"], + grant_types_supported: ["authorization_code", "refresh_token"], + token_endpoint_auth_methods_supported: ["none"], + code_challenge_methods_supported: ["S256"], + scopes_supported: MCP_SCOPES, + // DCR remains supported by MCP 2026-07-28 for compatibility. We advertise + // CIMD false until the server has a fetcher that can pin DNS and safely + // retrieve arbitrary client metadata without opening an SSRF surface. + client_id_metadata_document_supported: false, +}); + +const RESOURCE_METADATA = () => ({ + resource: MCP_RESOURCE, + authorization_servers: [config.origin], + scopes_supported: MCP_SCOPES, + bearer_methods_supported: ["header"], + resource_name: "Moshcode live sessions", +}); + +mcpOAuthMachineRouter.get("/.well-known/oauth-authorization-server", (_req, res) => + res.json(AS_METADATA())); +mcpOAuthMachineRouter.get("/.well-known/oauth-protected-resource", (_req, res) => + res.json(RESOURCE_METADATA())); +mcpOAuthMachineRouter.get("/.well-known/oauth-protected-resource/mcp", (_req, res) => + res.json(RESOURCE_METADATA())); + +mcpOAuthMachineRouter.post("/oauth/register", async (req, res) => { + try { + const client = await registerOAuthClient(req.body || {}); + res.status(201).json({ + client_id: client.client_id, + client_name: client.client_name, + redirect_uris: client.redirect_uris, + application_type: client.application_type, + client_uri: client.client_uri || undefined, + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }); + } catch (error) { + res.status(400).json({ + error: "invalid_client_metadata", + error_description: error.message, + }); + } +}); + +mcpOAuthMachineRouter.post("/oauth/token", async (req, res) => { + res.set("Cache-Control", "no-store"); + const grant = String(req.body?.grant_type || ""); + try { + if (grant === "authorization_code") { + const tokens = await exchangeAuthorizationCode({ + code: req.body?.code, + clientId: req.body?.client_id, + redirectUri: req.body?.redirect_uri, + verifier: req.body?.code_verifier, + resource: req.body?.resource || MCP_RESOURCE, + }); + return res.json(tokens); + } + if (grant === "refresh_token") { + const tokens = await rotateRefreshToken({ + refreshToken: req.body?.refresh_token, + clientId: req.body?.client_id, + }); + return res.json(tokens); + } + return res.status(400).json({ + error: "unsupported_grant_type", + error_description: "Use authorization_code or refresh_token.", + }); + } catch (error) { + return res.status(400).json({ + error: "invalid_grant", + error_description: error.message, + }); + } +}); + +function authRedirect(redirectUri, values) { + const url = new URL(redirectUri); + for (const [key, value] of Object.entries(values)) { + if (value !== "" && value != null) url.searchParams.set(key, String(value)); + } + return url.toString(); +} + +function describeScope(scope) { + if (scope === "sessions:read") return "See your Moshcode sessions and read mirrored terminal output."; + if (scope === "sessions:control") return "Queue commands or supported key presses into the selected live session."; + return scope; +} + +mcpOAuthBrowserRouter.get("/oauth/authorize", requireAuth, async (req, res) => { + try { + const auth = await validateAuthorizationRequest(req.query); + const sessions = await sessionsForAuthorization(req.user.id); + const scopeRows = auth.scopes.map((scope) => + `
  • ${esc(scope)}
    ${esc(describeScope(scope))}
  • ` + ).join(""); + const sessionRows = sessions.map((s, index) => { + const label = [s.name || "mosh", s.host, s.cwd].filter(Boolean).join(" · "); + return ``; + }).join(""); + + res.type("html").send(page({ + title: "Authorize MCP · moshcode", + head: ``, + body: `
    +
    +
    REMOTE MCP
    +

    Let ${esc(auth.client.client_name)} use Moshcode?

    +

    This client is asking to connect to ${esc(MCP_RESOURCE)} as ${esc(req.user.email || req.user.display_name || "you")}.

    +

    Permissions

    +
      ${scopeRows}
    +

    Session access

    +

    Bind this authorization to one session for least privilege, or allow all of your sessions.

    +
    + ${csrfInput(req)} + + + + + + + + +
    + + ${sessionRows || `
    No mirrored sessions yet. You can still authorize all sessions and start one later.
    `} +
    +
    + + +
    +
    +
    +
    `, + })); + } catch (error) { + res.status(400).type("html").send(page({ + title: "OAuth error · moshcode", + body: `

    Could not authorize that MCP client.

    ${esc(error.message)}

    `, + })); + } +}); + +mcpOAuthBrowserRouter.post("/oauth/authorize", requireAuth, async (req, res) => { + let auth; + try { + auth = await validateAuthorizationRequest(req.body || {}); + } catch (error) { + return res.status(400).type("text").send(`invalid authorization request: ${error.message}\n`); + } + + if (req.body?.decision !== "allow") { + return res.redirect(authRedirect(auth.redirectUri, { + error: "access_denied", + state: auth.state, + iss: config.origin, + })); + } + + const sessionId = String(req.body?.session_id || "").trim() || null; + if (sessionId && !(await userOwnsSession(req.user.id, sessionId))) { + return res.status(400).type("text").send("That session does not belong to this account.\n"); + } + + const code = await createAuthorizationCode({ + userId: req.user.id, + clientId: auth.clientId, + redirectUri: auth.redirectUri, + scope: normalizeScopes(auth.scope).join(" "), + resource: auth.resource, + sessionId, + codeChallenge: auth.codeChallenge, + }); + + return res.redirect(authRedirect(auth.redirectUri, { + code, + state: auth.state, + iss: config.origin, + })); +}); From 8a7c6f39d7fdf09df582127be5e968e1bd256f72 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 11 Sep 2026 10:38:41 -0700 Subject: [PATCH 5/7] Expose live Moshcode sessions over MCP --- apps/pwa/src/routes/mcp.mjs | 389 ++++++++++++++++++++++++++++++++++++ 1 file changed, 389 insertions(+) create mode 100644 apps/pwa/src/routes/mcp.mjs diff --git a/apps/pwa/src/routes/mcp.mjs b/apps/pwa/src/routes/mcp.mjs new file mode 100644 index 0000000..00466e8 --- /dev/null +++ b/apps/pwa/src/routes/mcp.mjs @@ -0,0 +1,389 @@ +// Remote Model Context Protocol endpoint for live Moshcode sessions. +// +// The transport supports the current 2026-07-28 stateless lifecycle and the +// 2025 handshake-era lifecycle used by older clients. Application state is +// explicit: every operation that touches a terminal carries a Moshcode +// session_id; the MCP transport itself never invents a server-side MCP session. +import { Router } from "express"; +import { all, get, run } from "../db.mjs"; +import { id } from "../lib/crypto.mjs"; +import { + hasScope, + requireMcpAccess, + scopeChallenge, +} from "../lib/mcp-auth.mjs"; + +export const mcpRouter = Router(); + +export const MODERN_VERSION = "2026-07-28"; +export const LEGACY_VERSIONS = ["2025-11-25", "2025-06-18", "2025-03-26"]; +const SERVER_INFO = { name: "moshcode", title: "Moshcode", version: "0.1.0" }; +const STALE_MS = 90 * 1000; +const KEY_PREFIX = "\u001bmoshkey:"; +const KEY_NAMES = new Set(["up", "down", "left", "right", "enter"]); + +const isLive = (row) => + row?.status === "live" && Date.now() - Number(row.last_seen_at) < STALE_MS; + +const toBool = (value, fallback = false) => + value == null ? fallback : value === true || String(value).toLowerCase() === "true"; + +const capInt = (value, fallback, min, max) => { + const n = Number(value); + return Number.isSafeInteger(n) ? Math.min(max, Math.max(min, n)) : fallback; +}; + +function features(row) { + try { return JSON.parse(row.features || "[]"); } catch { return []; } +} + +async function sessionFor(auth, sessionId) { + const sid = String(sessionId || ""); + if (!sid) throw new Error("session_id is required"); + if (auth.session_id && auth.session_id !== sid) throw new Error("this token is bound to a different session"); + const row = await get(`SELECT * FROM cli_sessions WHERE id=? AND user_id=?`, [sid, auth.user_id]); + if (!row) throw new Error("no such session"); + return row; +} + +function sessionView(row) { + return { + id: row.id, + name: row.name, + host: row.host, + version: row.version, + cwd: row.cwd, + engine: row.engine, + status: isLive(row) ? "live" : "offline", + last_seen_at: Number(row.last_seen_at), + cols: row.cols == null ? null : Number(row.cols), + rows: row.rows == null ? null : Number(row.rows), + features: features(row), + }; +} + +async function listSessions(auth, args = {}) { + const includeOffline = toBool(args.include_offline, true); + const limit = capInt(args.limit, 20, 1, 50); + const params = [auth.user_id]; + let where = "user_id=?"; + if (auth.session_id) { + where += " AND id=?"; + params.push(auth.session_id); + } + params.push(limit); + const rows = await all( + `SELECT * FROM cli_sessions WHERE ${where} ORDER BY last_seen_at DESC LIMIT ?`, + params + ); + return { + sessions: rows.map(sessionView).filter((s) => includeOffline || s.status === "live"), + token_bound_session_id: auth.session_id || null, + }; +} + +async function readSession(auth, args = {}) { + const row = await sessionFor(auth, args.session_id); + const after = capInt(args.after_seq, 0, 0, Number.MAX_SAFE_INTEGER); + const limit = capInt(args.limit, 200, 1, 400); + const chunks = await all( + `SELECT seq,chunk,created_at FROM session_output + WHERE session_id=? AND seq>? ORDER BY seq ASC LIMIT ?`, + [row.id, after, limit] + ); + const output = chunks.map((item) => ({ + seq: Number(item.seq), + chunk: String(item.chunk), + created_at: Number(item.created_at), + })); + return { + session: sessionView(row), + after_seq: after, + next_seq: output.length ? output[output.length - 1].seq : after, + output, + text: output.map((item) => item.chunk).join(""), + }; +} + +function splitCommands(text) { + return String(text ?? "") + .split(/\r\n|\r|\n/) + .map((line) => line.trim()) + .filter(Boolean) + .filter((line) => !line.startsWith(KEY_PREFIX)) + .slice(0, 50) + .map((line) => line.slice(0, 500)); +} + +async function sendSession(auth, args = {}) { + const row = await sessionFor(auth, args.session_id); + if (!isLive(row)) throw new Error("session is offline"); + const lines = splitCommands(args.text); + if (!lines.length) throw new Error("text must contain at least one command"); + const now = Date.now(); + const commands = []; + for (const [index, body] of lines.entries()) { + const commandId = id(); + await run( + `INSERT INTO session_commands (id,session_id,body,status,created_at) + VALUES (?,?,?,'queued',?)`, + [commandId, row.id, body, now + index] + ); + commands.push({ id: commandId, body }); + } + return { + ok: true, + session_id: row.id, + queued: commands, + note: "Commands are queued for the live CLI. Delivery follows the CLI session long-poll window.", + }; +} + +async function pressSessionKey(auth, args = {}) { + const row = await sessionFor(auth, args.session_id); + if (!isLive(row)) throw new Error("session is offline"); + const key = String(args.key || "").toLowerCase(); + if (!KEY_NAMES.has(key)) throw new Error("key must be up, down, left, right, or enter"); + if (!features(row).includes("keys")) throw new Error("this Moshcode session does not advertise remote key support"); + const commandId = id(); + await run( + `INSERT INTO session_commands (id,session_id,body,status,created_at) + VALUES (?,?,?,'queued',?)`, + [commandId, row.id, KEY_PREFIX + key, Date.now()] + ); + return { ok: true, session_id: row.id, command_id: commandId, key }; +} + +const TOOL_DEFS = [ + { + name: "moshcode_sessions_list", + title: "List Moshcode sessions", + description: "List the authenticated user's mirrored Moshcode CLI sessions. A token may be restricted to one session.", + inputSchema: { + type: "object", + properties: { + include_offline: { type: "boolean", description: "Include stale or ended sessions. Defaults to true." }, + limit: { type: "integer", minimum: 1, maximum: 50, description: "Maximum sessions to return." }, + }, + additionalProperties: false, + }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + requiredScope: "sessions:read", + }, + { + name: "moshcode_session_read", + title: "Read Moshcode session output", + description: "Read sequenced terminal scrollback from a Moshcode session. Pass after_seq from the previous result to fetch only new output.", + inputSchema: { + type: "object", + properties: { + session_id: { type: "string", description: "Moshcode session id." }, + after_seq: { type: "integer", minimum: 0, description: "Only return output with seq greater than this value." }, + limit: { type: "integer", minimum: 1, maximum: 400, description: "Maximum output chunks to return." }, + }, + required: ["session_id"], + additionalProperties: false, + }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + requiredScope: "sessions:read", + }, + { + name: "moshcode_session_send", + title: "Send command to Moshcode", + description: "Queue one command or a short newline-separated command block into a live Moshcode prompt. This changes the remote development session.", + inputSchema: { + type: "object", + properties: { + session_id: { type: "string", description: "Live Moshcode session id." }, + text: { type: "string", minLength: 1, maxLength: 25000, description: "Command text. New lines are queued in order." }, + }, + required: ["session_id", "text"], + additionalProperties: false, + }, + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true }, + requiredScope: "sessions:control", + }, + { + name: "moshcode_session_key", + title: "Press a Moshcode navigation key", + description: "Queue one supported key press into a live Moshcode session. Useful for interactive prompts exposed by the Moshcode terminal mirror.", + inputSchema: { + type: "object", + properties: { + session_id: { type: "string", description: "Live Moshcode session id." }, + key: { type: "string", enum: ["up", "down", "left", "right", "enter"] }, + }, + required: ["session_id", "key"], + additionalProperties: false, + }, + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true }, + requiredScope: "sessions:control", + }, +]; + +export function toolsFor(auth) { + return TOOL_DEFS + .filter((tool) => hasScope(auth, tool.requiredScope)) + .map(({ requiredScope, ...tool }) => tool); +} + +async function invokeTool(auth, name, args) { + const def = TOOL_DEFS.find((tool) => tool.name === name); + if (!def) throw Object.assign(new Error(`unknown tool: ${name}`), { code: -32602 }); + if (!hasScope(auth, def.requiredScope)) { + throw Object.assign(new Error(`scope ${def.requiredScope} is required`), { + code: -32003, + requiredScope: def.requiredScope, + }); + } + if (name === "moshcode_sessions_list") return listSessions(auth, args); + if (name === "moshcode_session_read") return readSession(auth, args); + if (name === "moshcode_session_send") return sendSession(auth, args); + if (name === "moshcode_session_key") return pressSessionKey(auth, args); + throw Object.assign(new Error(`unimplemented tool: ${name}`), { code: -32601 }); +} + +const serverMeta = () => ({ "io.modelcontextprotocol/serverInfo": SERVER_INFO }); + +function modernResult(payload, { cache = false } = {}) { + return { + ...payload, + resultType: "complete", + ...(cache ? { ttlMs: 0, cacheScope: "private" } : {}), + _meta: { ...(payload?._meta || {}), ...serverMeta() }, + }; +} + +function toolResult(data, modern) { + const result = { + content: [{ type: "text", text: JSON.stringify(data, null, 2) }], + structuredContent: data, + }; + return modern ? modernResult(result) : result; +} + +function jsonRpcError(idValue, code, message, data) { + return { + jsonrpc: "2.0", + id: idValue ?? null, + error: { code, message, ...(data === undefined ? {} : { data }) }, + }; +} + +function versionFor(req, body) { + const header = String(req.get("mcp-protocol-version") || ""); + const meta = body?.params?._meta?.["io.modelcontextprotocol/protocolVersion"]; + return header || (meta ? String(meta) : ""); +} + +function headerMismatch(message) { + return Object.assign(new Error(message), { code: -32020, httpStatus: 400 }); +} + +function validateModernHeaders(req, body) { + const protocol = String(req.get("mcp-protocol-version") || ""); + if (protocol !== MODERN_VERSION) throw headerMismatch("MCP-Protocol-Version header is required and must match 2026-07-28"); + const method = String(req.get("mcp-method") || ""); + if (!method) throw headerMismatch("Mcp-Method header is required for MCP 2026-07-28"); + if (method !== body.method) throw headerMismatch("Mcp-Method header does not match JSON-RPC method"); + if (body.method === "tools/call") { + const name = String(req.get("mcp-name") || ""); + if (!name) throw headerMismatch("Mcp-Name header is required for tools/call"); + if (name !== String(body.params?.name || "")) { + throw headerMismatch("Mcp-Name header does not match tool name"); + } + } +} + +async function dispatch(req, auth, body) { + if (!body || body.jsonrpc !== "2.0" || typeof body.method !== "string") { + throw Object.assign(new Error("invalid JSON-RPC request"), { code: -32600 }); + } + const version = versionFor(req, body); + const modern = version === MODERN_VERSION; + + if (modern) validateModernHeaders(req, body); + + if (body.method === "server/discover") { + if (!modern) throw Object.assign(new Error("server/discover requires MCP 2026-07-28"), { code: -32601 }); + return modernResult({ + supportedVersions: [MODERN_VERSION], + capabilities: { tools: { listChanged: false } }, + instructions: "Use moshcode_sessions_list to find a session, moshcode_session_read with after_seq to observe it, and control tools only when the user intended the remote session to change.", + }, { cache: true }); + } + + if (body.method === "initialize") { + const requested = String(body.params?.protocolVersion || ""); + const negotiated = LEGACY_VERSIONS.includes(requested) ? requested : LEGACY_VERSIONS[0]; + return { + protocolVersion: negotiated, + capabilities: { tools: { listChanged: false } }, + serverInfo: SERVER_INFO, + instructions: "Use the session list/read tools for context before sending commands to a live Moshcode session.", + }; + } + + if (body.method === "ping") return modern ? modernResult({}) : {}; + + if (body.method === "tools/list") { + const result = { tools: toolsFor(auth) }; + return modern ? modernResult(result, { cache: true }) : result; + } + + if (body.method === "tools/call") { + const name = String(body.params?.name || ""); + try { + return toolResult(await invokeTool(auth, name, body.params?.arguments || {}), modern); + } catch (error) { + if (error.requiredScope || Number.isInteger(error.code)) throw error; + return { + ...(modern ? modernResult({}) : {}), + content: [{ type: "text", text: error.message || "tool call failed" }], + isError: true, + }; + } + } + + throw Object.assign(new Error(`method not found: ${body.method}`), { code: -32601 }); +} + +mcpRouter.get("/mcp", (_req, res) => { + res.set("Allow", "POST"); + res.status(405).json({ error: "Moshcode MCP uses stateless HTTP POST." }); +}); + +mcpRouter.post("/mcp", requireMcpAccess, async (req, res) => { + const body = req.body; + // initialized is a JSON-RPC notification in legacy clients and has no response. + if (body?.method === "notifications/initialized" && body?.id === undefined) { + return res.status(202).end(); + } + + try { + const result = await dispatch(req, req.mcpAuth, body); + // A notification has no JSON-RPC response. + if (body?.id === undefined) return res.status(202).end(); + res.set("Cache-Control", "no-store"); + return res.json({ jsonrpc: "2.0", id: body.id, result }); + } catch (error) { + if (error.requiredScope) { + scopeChallenge(res, error.requiredScope); + res.status(403); + } + const status = error.httpStatus || (res.statusCode >= 400 ? res.statusCode : 200); + return res.status(status).json(jsonRpcError( + body?.id, + Number.isInteger(error.code) ? error.code : -32603, + error.message || "internal error" + )); + } +}); + +export const __test = { + splitCommands, + sessionView, + versionFor, + modernResult, + jsonRpcError, +}; From 73b48fdb4033c2ab9609fce92b758fe9b2ac9f6c Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 11 Sep 2026 10:39:15 -0700 Subject: [PATCH 6/7] Test remote MCP OAuth and session controls --- apps/pwa/test/mcp-remote.test.mjs | 261 ++++++++++++++++++++++++++++++ 1 file changed, 261 insertions(+) create mode 100644 apps/pwa/test/mcp-remote.test.mjs diff --git a/apps/pwa/test/mcp-remote.test.mjs b/apps/pwa/test/mcp-remote.test.mjs new file mode 100644 index 0000000..49fe920 --- /dev/null +++ b/apps/pwa/test/mcp-remote.test.mjs @@ -0,0 +1,261 @@ +// Integration tests for the remote MCP + OAuth surface. +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; +import test from "node:test"; + +const require = createRequire(import.meta.url); +let deps = null; +try { + deps = { express: require("express"), cookieParser: require("cookie-parser") }; +} catch { + deps = null; +} + +const workdir = mkdtempSync(path.join(tmpdir(), "moshcode-mcp-test-")); +process.env.DATABASE_URL = `file:${path.join(workdir, "test.db")}`; +process.env.PUBLIC_ORIGIN = "https://app.moshcode.test"; +process.env.SESSION_SECRET = "mcp-test-secret"; + +async function boot() { + const { migrate } = await import("../src/migrate.mjs"); + await migrate(); + const { run, all, db } = await import("../src/db.mjs"); + const { mcpRouter } = await import("../src/routes/mcp.mjs"); + const { mcpOAuthMachineRouter } = await import("../src/routes/mcp-oauth.mjs"); + const { + MCP_RESOURCE, + createAuthorizationCode, + exchangeAuthorizationCode, + pkceChallenge, + registerOAuthClient, + rotateRefreshToken, + } = await import("../src/lib/mcp-auth.mjs"); + + const app = deps.express(); + app.use(deps.express.json()); + app.use(deps.express.urlencoded({ extended: false })); + app.use(deps.cookieParser()); + app.use(mcpOAuthMachineRouter); + app.use(mcpRouter); + const server = await new Promise((resolve) => { + const s = app.listen(0, "127.0.0.1", () => resolve(s)); + }); + const base = `http://127.0.0.1:${server.address().port}`; + + await run(`INSERT OR REPLACE INTO users (id,email,display_name,created_at) VALUES ('u1','mcp@example.test','MCP Test',1)`); + + async function tokenFor(scope, sessionId = null) { + const client = await registerOAuthClient({ + client_name: "test client", + redirect_uris: ["https://client.example.test/callback"], + }); + const verifier = crypto.randomBytes(48).toString("base64url").slice(0, 64); + const code = await createAuthorizationCode({ + userId: "u1", + clientId: client.client_id, + redirectUri: client.redirect_uris[0], + scope, + resource: MCP_RESOURCE, + sessionId, + codeChallenge: pkceChallenge(verifier), + }); + const result = await exchangeAuthorizationCode({ + code, + clientId: client.client_id, + redirectUri: client.redirect_uris[0], + verifier, + resource: MCP_RESOURCE, + }); + return { ...result, __client_id: client.client_id }; + } + + return { + run, all, db, server, base, tokenFor, MCP_RESOURCE, + createAuthorizationCode, exchangeAuthorizationCode, pkceChallenge, registerOAuthClient, rotateRefreshToken, + }; +} + +let booted = null; +const app = () => (booted ||= boot()); + +test.after(() => { + if (!booted) return; + booted.then(({ server, db }) => { server.close(); db.close?.(); }) + .finally(() => { try { fs.rmSync(workdir, { recursive: true, force: true }); } catch { /* noop */ } }); +}); + +test("MCP protected resource returns an OAuth challenge without a token", { skip: !deps && "apps/pwa deps not installed" }, async () => { + const { base } = await app(); + const res = await fetch(`${base}/mcp`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }), + }); + assert.equal(res.status, 401); + assert.match(res.headers.get("www-authenticate") || "", /oauth-protected-resource\/mcp/); +}); + +test("OAuth authorization codes are PKCE-protected and single-use", { skip: !deps && "apps/pwa deps not installed" }, async () => { + const { MCP_RESOURCE, createAuthorizationCode, exchangeAuthorizationCode, pkceChallenge, registerOAuthClient } = await app(); + const client = await registerOAuthClient({ + client_name: "single-use test", + redirect_uris: ["https://client.example.test/replay"], + }); + const verifier = crypto.randomBytes(48).toString("base64url").slice(0, 64); + const code = await createAuthorizationCode({ + userId: "u1", + clientId: client.client_id, + redirectUri: client.redirect_uris[0], + scope: "sessions:read", + resource: MCP_RESOURCE, + codeChallenge: pkceChallenge(verifier), + }); + + await assert.rejects( + exchangeAuthorizationCode({ + code, clientId: client.client_id, redirectUri: client.redirect_uris[0], + verifier: "wrong-" + verifier, resource: MCP_RESOURCE, + }), + /PKCE verification failed/ + ); + + const tokens = await exchangeAuthorizationCode({ + code, clientId: client.client_id, redirectUri: client.redirect_uris[0], + verifier, resource: MCP_RESOURCE, + }); + assert.match(tokens.access_token, /^mca_/); + assert.match(tokens.refresh_token, /^mcr_/); + assert.equal(tokens.scope, "sessions:read"); + + await assert.rejects( + exchangeAuthorizationCode({ + code, clientId: client.client_id, redirectUri: client.redirect_uris[0], + verifier, resource: MCP_RESOURCE, + }), + /invalid or expired authorization code|already used/ + ); +}); + +test("OAuth refresh tokens rotate and cannot be replayed", { skip: !deps && "apps/pwa deps not installed" }, async () => { + const { tokenFor, rotateRefreshToken } = await app(); + const first = await tokenFor("sessions:read"); + const second = await rotateRefreshToken({ refreshToken: first.refresh_token, clientId: first.__client_id }); + assert.match(second.access_token, /^mca_/); + assert.notEqual(second.refresh_token, first.refresh_token); + await assert.rejects( + rotateRefreshToken({ refreshToken: first.refresh_token, clientId: first.__client_id }), + /invalid refresh_token|already used/ + ); +}); + +test("read-only MCP tokens only discover read tools", { skip: !deps && "apps/pwa deps not installed" }, async () => { + const { base, tokenFor } = await app(); + const tokens = await tokenFor("sessions:read"); + const res = await fetch(`${base}/mcp`, { + method: "POST", + headers: { + authorization: `Bearer ${tokens.access_token}`, + "content-type": "application/json", + "mcp-protocol-version": "2026-07-28", + "mcp-method": "tools/list", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 2, + method: "tools/list", + params: { + _meta: { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { name: "test", version: "1" }, + "io.modelcontextprotocol/clientCapabilities": {}, + }, + }, + }), + }); + assert.equal(res.status, 200); + const body = await res.json(); + const names = body.result.tools.map((tool) => tool.name); + assert.deepEqual(names, ["moshcode_sessions_list", "moshcode_session_read"]); + assert.equal(body.result.resultType, "complete"); + assert.equal(body.result.cacheScope, "private"); +}); + +test("control token can queue a command into its bound live session", { skip: !deps && "apps/pwa deps not installed" }, async () => { + const { base, tokenFor, run, all } = await app(); + const now = Date.now(); + await run( + `INSERT INTO cli_sessions + (id,user_id,name,host,version,cwd,cols,rows,features,status,created_at,last_seen_at) + VALUES ('s1','u1','test session','host','1.0','/tmp',80,24,'["keys"]','live',?,?)`, + [now, now] + ); + const tokens = await tokenFor("sessions:control", "s1"); + const res = await fetch(`${base}/mcp`, { + method: "POST", + headers: { + authorization: `Bearer ${tokens.access_token}`, + "content-type": "application/json", + "mcp-protocol-version": "2026-07-28", + "mcp-method": "tools/call", + "mcp-name": "moshcode_session_send", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 3, + method: "tools/call", + params: { + name: "moshcode_session_send", + arguments: { session_id: "s1", text: "git status" }, + _meta: { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { name: "test", version: "1" }, + "io.modelcontextprotocol/clientCapabilities": {}, + }, + }, + }), + }); + assert.equal(res.status, 200); + const body = await res.json(); + assert.equal(body.result.isError, undefined); + const commands = await all(`SELECT body,status FROM session_commands WHERE session_id='s1'`); + assert.deepEqual(commands.map((r) => [r.body, r.status]), [["git status", "queued"]]); +}); + +test("a session-bound token cannot read a different session", { skip: !deps && "apps/pwa deps not installed" }, async () => { + const { base, tokenFor, run } = await app(); + const now = Date.now(); + await run( + `INSERT OR IGNORE INTO cli_sessions + (id,user_id,name,features,status,created_at,last_seen_at) + VALUES ('s-bound','u1','bound','[]','live',?,?)`, + [now, now] + ); + await run( + `INSERT OR IGNORE INTO cli_sessions + (id,user_id,name,features,status,created_at,last_seen_at) + VALUES ('s-other','u1','other','[]','live',?,?)`, + [now, now] + ); + const tokens = await tokenFor("sessions:read", "s-bound"); + const res = await fetch(`${base}/mcp`, { + method: "POST", + headers: { + authorization: `Bearer ${tokens.access_token}`, + "content-type": "application/json", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 4, + method: "tools/call", + params: { name: "moshcode_session_read", arguments: { session_id: "s-other" } }, + }), + }); + const body = await res.json(); + assert.equal(body.result.isError, true); + assert.match(body.result.content[0].text, /bound to a different session/); +}); From 6e7d94cfe78d7e99c58f6228a935bb2ec154242f Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 11 Sep 2026 10:39:48 -0700 Subject: [PATCH 7/7] Wire remote MCP and OAuth into the PWA --- apps/pwa/src/server.mjs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/pwa/src/server.mjs b/apps/pwa/src/server.mjs index b2362c1..c25c889 100644 --- a/apps/pwa/src/server.mjs +++ b/apps/pwa/src/server.mjs @@ -12,6 +12,8 @@ import { approvalsRouter } from "./routes/approvals.mjs"; import { creditsRouter } from "./routes/credits.mjs"; import { cliRouter } from "./routes/cli.mjs"; import { sessionsRouter } from "./routes/sessions.mjs"; +import { mcpRouter } from "./routes/mcp.mjs"; +import { mcpOAuthBrowserRouter, mcpOAuthMachineRouter } from "./routes/mcp-oauth.mjs"; import { pagesRouter } from "./routes/pages.mjs"; import { settingsSyncRouter } from "./routes/settings-sync.mjs"; import { moshpitRouter } from "./routes/moshpit.mjs"; @@ -62,6 +64,13 @@ for (const [route, file] of Object.entries(vendor)) { app.get("/healthz", (_req, res) => res.json({ ok: true, env: config.env })); app.use(sessionMiddleware); + +// OAuth token/registration and MCP calls are machine endpoints. Mount them +// before browser CSRF; each MCP call is bearer-authenticated and token issuance +// is protected by authorization-code + PKCE instead of cookies. +app.use(mcpOAuthMachineRouter); +app.use(mcpRouter); + app.use(csrfGuard); // routes @@ -72,6 +81,7 @@ app.use(approvalsRouter); app.use(creditsRouter); app.use(cliRouter); // /cli/authorize, /cli/token, /api/me app.use(sessionsRouter); // /sessions (live CLI mirror) + /api/sessions +app.use(mcpOAuthBrowserRouter); // /oauth/authorize — logged-in consent + CSRF app.use(pagesRouter); // /app, /settings app.use(settingsSyncRouter); // /api/settings (+ /settings/sync) — the pit's /save and /load app.use(socialsRouter); // public browser composers used by /post @@ -91,7 +101,9 @@ app.use((err, req, res, _next) => { const detail = err?.type === "entity.too.large" ? `that body is too large. Publish up to ${MAX_BATCH} items at a time, and split the batch if it is still refused — publishing upserts on the slug, so a split batch is safe to retry.` : "could not read that request body as JSON"; - if (req.path.startsWith("/api/")) return res.status(status).json({ error: detail }); + if (req.path.startsWith("/api/") || req.path === "/mcp" || req.path.startsWith("/oauth/")) { + return res.status(status).json({ error: detail }); + } return res.status(status).type("text").send(`${detail}\n`); } console.error(err);