diff --git a/src/mcp-connector/README.md b/src/mcp-connector/README.md index 17e2f988e..8971c053f 100644 --- a/src/mcp-connector/README.md +++ b/src/mcp-connector/README.md @@ -4,9 +4,10 @@ An [MCP](https://modelcontextprotocol.io) server that runs on Cloudflare Workers and lets **anyone use their own Puter auth token** to drive their Puter account's filesystem and subdomains from an MCP client (Claude, Cursor, etc.). -The Worker stores **no credentials of its own**. Every request carries the -caller's token via a standard `Authorization: Bearer ` header, and all -operations run as that user. +The Worker stores **no credentials of its own**. Every request runs as the +caller, authenticated either by a `Authorization: Bearer ` header or by an +OAuth "Sign in with Puter" flow the Worker hosts itself (see +[Authentication](#authentication)). ## Tools @@ -76,8 +77,9 @@ registered onto the forked router as routes. | Path | Role | | --- | --- | | [`src/s2w-router.js`](src/s2w-router.js) | Forked router: builds `event.user.puter` from the bearer token (no `me.puter`). | -| [`src/index.js`](src/index.js) | Entry: `initS2w()` + registers MCP routes. | -| [`src/mcp.js`](src/mcp.js) | MCP JSON-RPC dispatch (initialize / tools.list / tools.call). | +| [`src/index.js`](src/index.js) | Entry: `initS2w()` + registers MCP and OAuth routes. | +| [`src/mcp.js`](src/mcp.js) | MCP JSON-RPC dispatch; 401 + `WWW-Authenticate` when unauthenticated. | +| [`src/oauth.js`](src/oauth.js) | OAuth bridge: discovery, `/register`, `/authorize`→authme, `/oauth/callback`, `/token`. | | [`src/tools.js`](src/tools.js) | The 12 tools, calling real `puter.fs.*` / `puter.hosting.*`. | | [`template/puter-portable.template`](template/puter-portable.template) | Preamble template (defines `init_puter_portable`, inlines puter.js). | @@ -95,18 +97,44 @@ npm run dev # builds, then wrangler dev — serves on http://localhost:87 npm run deploy # builds, then wrangler deploy ``` -To target a self-hosted Puter instance, set `puter_endpoint` (uncomment the -`[vars]` block in `wrangler.toml`). The router reads `globalThis.puter_endpoint` -and defaults to `https://api.puter.com`. +To target a self-hosted Puter instance, set `puter_endpoint` / `puter_gui_origin` +(uncomment the `[vars]` block in `wrangler.toml`). They default to +`https://api.puter.com` and `https://puter.com`. -## Getting a Puter token +If you use the OAuth flow (below), also set the sealing secret in production: -In a browser logged into Puter, open the devtools console and run -`puter.authToken`. Treat it like a password. +```bash +wrangler secret put OAUTH_SECRET +``` + +## Authentication + +Two ways, both running as the caller — the Worker holds no credentials of its own: + +1. **OAuth "Sign in with Puter"** (no token to copy). For clients that support + OAuth over HTTP (e.g. Claude Code), the Worker *is* the authorization server: + on first use the client opens a browser, you sign into Puter and approve, and + the Worker hands the client your Puter token. See + [`src/oauth.js`](src/oauth.js). Under the hood it redirects to Puter's + `?action=authme` page and catches the returned token on its `/oauth/callback`; + the short-lived flow/code blobs are AES-GCM sealed with `OAUTH_SECRET`, so the + Worker stays stateless. +2. **Bearer token** (copy/paste). Get it from a logged-in Puter browser tab's + devtools console: `puter.authToken`. Treat it like a password. Pass it as an + `Authorization: Bearer ` header (or the `.mcpb` token field). ## Connecting a client -### Option A — one-click `.mcpb` bundle (Claude Desktop etc.) +### Option A — Claude Code (OAuth, no token) + +```bash +claude mcp add --transport http puter https://puter-mcp..workers.dev/ +``` + +On first use Claude Code opens a browser to sign in with Puter; approve, and it's +connected. (If you'd rather skip OAuth, add `-H "Authorization: Bearer YOUR_PUTER_TOKEN"`.) + +### Option B — one-click `.mcpb` bundle (Claude Desktop etc.) [`puter-mcp-connector.mcpb`](puter-mcp-connector.mcpb) is a prebuilt [MCP Bundle](https://github.com/anthropics/mcpb). Import it into a host that supports @@ -131,7 +159,7 @@ npm run pack:mcpb # -> puter-mcp-connector.mcpb The `.mcpb` is unsigned; hosts may warn it's from an unknown developer. To self-sign: `npx @anthropic-ai/mcpb sign --self-signed puter-mcp-connector.mcpb`. -### Option B — direct HTTP +### Option C — direct HTTP Point any MCP client that supports HTTP transport at the Worker URL and add your token as a bearer header. Example (`mcp.json`-style): diff --git a/src/mcp-connector/src/index.js b/src/mcp-connector/src/index.js index f42e0c8af..074726829 100644 --- a/src/mcp-connector/src/index.js +++ b/src/mcp-connector/src/index.js @@ -1,6 +1,10 @@ import initS2w from './s2w-router.js'; import registerMcpRoutes from './mcp.js'; +import registerOAuthRoutes from './oauth.js'; -// Bring up the (forked) Puter worker router, then register the MCP routes on it. +// Bring up the (forked) Puter worker router, then register the routes on it. initS2w(); registerMcpRoutes(globalThis.router); +// OAuth bridge — lets clients obtain the caller's Puter token via "Sign in with +// Puter" instead of pasting it. The Authorization: Bearer path is unchanged. +registerOAuthRoutes(globalThis.router); diff --git a/src/mcp-connector/src/mcp.js b/src/mcp-connector/src/mcp.js index 9153ba862..919771eee 100644 --- a/src/mcp-connector/src/mcp.js +++ b/src/mcp-connector/src/mcp.js @@ -148,6 +148,25 @@ function jsonResponse(body) { async function mcpPost(event) { const userPuter = event.user && event.user.puter; + // No bearer token: this is a protected resource, so reply 401 with a + // WWW-Authenticate pointing at our resource metadata. That's the signal an + // MCP client (e.g. Claude Code) uses to start the OAuth flow (/authorize). + // Clients that pass Authorization: Bearer never hit this branch. + if (!userPuter) { + const origin = new URL(event.request.url).origin; + return new Response( + JSON.stringify(rpcError(null, INVALID_REQUEST, 'Authentication required')), + { + status: 401, + headers: { + 'content-type': 'application/json', + 'Access-Control-Allow-Origin': '*', + 'WWW-Authenticate': `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource"`, + }, + }, + ); + } + let payload; try { payload = await event.request.json(); diff --git a/src/mcp-connector/src/oauth.js b/src/mcp-connector/src/oauth.js new file mode 100644 index 000000000..7cdfe3ac1 --- /dev/null +++ b/src/mcp-connector/src/oauth.js @@ -0,0 +1,260 @@ +// OAuth 2.0 bridge: lets MCP clients (e.g. Claude Code) obtain the caller's +// Puter token automatically instead of pasting it. +// +// The worker IS the authorization server. It never opens a browser — the MCP +// client does, by navigating to /authorize. From there: +// +// client → GET /authorize -> 302 to puter.com/?action=authme&redirectURL=/oauth/callback?flow=… +// (user logs in + approves on puter.com; Puter 302s back with ?token=…) +// puter → GET /oauth/callback -> 302 to ?code=…&state=… +// client → POST /token -> { access_token: } +// client → MCP calls with Authorization: Bearer (the existing path) +// +// Stateless: the short-lived `flow` (authorize→callback) and `code` +// (callback→token) blobs are AES-GCM sealed with a worker secret, so nothing is +// persisted. PKCE (S256) is enforced when the client provides a challenge. +// +// Discovery: serves RFC 8414 (authorization-server) and RFC 9728 +// (protected-resource) metadata, plus RFC 7591 dynamic client registration. + +const DEFAULT_GUI_ORIGIN = 'https://puter.com'; +const FLOW_TTL_MS = 10 * 60 * 1000; // authorize -> callback +const CODE_TTL_MS = 5 * 60 * 1000; // callback -> token + +const guiOrigin = () => globalThis.puter_gui_origin || DEFAULT_GUI_ORIGIN; + +// A stable secret is required to seal/unseal across requests and isolates. +// Set it in production: `wrangler secret put OAUTH_SECRET`. The dev fallback +// keeps local `wrangler dev` working but must NOT be relied on in production. +const secretString = () => + globalThis.OAUTH_SECRET || 'puter-mcp-dev-insecure-secret-change-me'; + +const originOf = (request) => new URL(request.url).origin; + +// ---- base64url + AES-GCM sealing ------------------------------------------ + +function b64urlEncode(bytes) { + let s = ''; + for (let i = 0; i < bytes.length; i += 1) s += String.fromCharCode(bytes[i]); + return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +function b64urlDecode(str) { + let s = str.replace(/-/g, '+').replace(/_/g, '/'); + while (s.length % 4) s += '='; + const bin = atob(s); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i += 1) out[i] = bin.charCodeAt(i); + return out; +} + +async function aesKey() { + const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(secretString())); + return crypto.subtle.importKey('raw', hash, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']); +} + +async function seal(obj) { + const key = await aesKey(); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const data = new TextEncoder().encode(JSON.stringify(obj)); + const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, data)); + const out = new Uint8Array(iv.length + ct.length); + out.set(iv, 0); + out.set(ct, iv.length); + return b64urlEncode(out); +} + +async function unseal(blob) { + const key = await aesKey(); + const raw = b64urlDecode(blob); + const iv = raw.slice(0, 12); + const ct = raw.slice(12); + const pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ct); + return JSON.parse(new TextDecoder().decode(pt)); +} + +async function sha256b64url(str) { + const h = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(str)); + return b64urlEncode(new Uint8Array(h)); +} + +// ---- response helpers ------------------------------------------------------ + +function json(status, obj) { + return new Response(JSON.stringify(obj), { + status, + headers: { 'content-type': 'application/json', 'Access-Control-Allow-Origin': '*' }, + }); +} + +function redirect(location) { + return new Response(null, { + status: 302, + headers: { Location: location, 'Access-Control-Allow-Origin': '*' }, + }); +} + +function redirectWithParams(baseUrl, params) { + const u = new URL(baseUrl); + for (const [k, v] of Object.entries(params)) { + if (v !== undefined && v !== null && v !== '') u.searchParams.set(k, v); + } + return redirect(u.href); +} + +// ---- metadata (RFC 8414 / RFC 9728) --------------------------------------- + +function authServerMetadata(event) { + const origin = originOf(event.request); + return json(200, { + issuer: origin, + authorization_endpoint: `${origin}/authorize`, + token_endpoint: `${origin}/token`, + registration_endpoint: `${origin}/register`, + response_types_supported: ['code'], + grant_types_supported: ['authorization_code'], + code_challenge_methods_supported: ['S256', 'plain'], + token_endpoint_auth_methods_supported: ['none'], + scopes_supported: ['puter'], + }); +} + +function protectedResourceMetadata(event) { + const origin = originOf(event.request); + return json(200, { + resource: origin, + authorization_servers: [origin], + }); +} + +// ---- endpoints ------------------------------------------------------------- + +// GET /authorize — kick off the flow by sending the browser to Puter's authme. +async function authorize(event) { + const p = new URL(event.request.url).searchParams; + const responseType = p.get('response_type'); + const redirectUri = p.get('redirect_uri'); + const clientState = p.get('state') || ''; + const codeChallenge = p.get('code_challenge') || ''; + const codeChallengeMethod = p.get('code_challenge_method') || (codeChallenge ? 'plain' : ''); + + if (responseType !== 'code' || !redirectUri) { + return json(400, { + error: 'invalid_request', + error_description: 'response_type=code and redirect_uri are required', + }); + } + + const flow = await seal({ + redirectUri, + clientState, + codeChallenge, + codeChallengeMethod, + ts: Date.now(), + }); + + const callbackUrl = `${originOf(event.request)}/oauth/callback?flow=${encodeURIComponent(flow)}`; + const dest = `${guiOrigin()}/?action=authme&redirectURL=${encodeURIComponent(callbackUrl)}`; + return redirect(dest); +} + +// GET /oauth/callback — Puter redirected here with ?token=… ; mint an auth code. +async function callback(event) { + const params = new URL(event.request.url).searchParams; + const flowBlob = params.get('flow'); + const token = params.get('token'); + + let flow; + try { + flow = await unseal(flowBlob); + } catch { + return json(400, { error: 'invalid_request', error_description: 'invalid or missing flow' }); + } + if (!flow || Date.now() - flow.ts > FLOW_TTL_MS) { + return json(400, { error: 'invalid_request', error_description: 'authorization flow expired' }); + } + + // No token => user declined the authme consent (or Puter returned nothing). + if (!token) { + return redirectWithParams(flow.redirectUri, { error: 'access_denied', state: flow.clientState }); + } + + const code = await seal({ + token, + codeChallenge: flow.codeChallenge, + codeChallengeMethod: flow.codeChallengeMethod, + ts: Date.now(), + }); + return redirectWithParams(flow.redirectUri, { code, state: flow.clientState }); +} + +async function parseForm(request) { + const ct = request.headers.get('content-type') || ''; + if (ct.includes('application/json')) { + const obj = await request.json().catch(() => ({})); + return new URLSearchParams(Object.entries(obj).map(([k, v]) => [k, String(v)])); + } + return new URLSearchParams(await request.text()); +} + +// POST /token — exchange the auth code (+PKCE) for the Puter token. +async function token(event) { + const form = await parseForm(event.request); + if (form.get('grant_type') !== 'authorization_code') { + return json(400, { error: 'unsupported_grant_type' }); + } + + const code = form.get('code') || ''; + const verifier = form.get('code_verifier') || ''; + + let payload; + try { + payload = await unseal(code); + } catch { + return json(400, { error: 'invalid_grant', error_description: 'invalid authorization code' }); + } + if (!payload || Date.now() - payload.ts > CODE_TTL_MS) { + return json(400, { error: 'invalid_grant', error_description: 'authorization code expired' }); + } + + // PKCE: enforce only if the client supplied a challenge at /authorize. + if (payload.codeChallenge) { + const ok = payload.codeChallengeMethod === 'S256' + ? (await sha256b64url(verifier)) === payload.codeChallenge + : verifier === payload.codeChallenge; + if (!ok) { + return json(400, { error: 'invalid_grant', error_description: 'PKCE verification failed' }); + } + } + + // The access token IS the Puter token — the bearer path consumes it as-is. + return json(200, { access_token: payload.token, token_type: 'Bearer', scope: 'puter' }); +} + +// POST /register — dynamic client registration (RFC 7591). We don't persist +// clients; security rests on PKCE + the redirect_uri sealed into the flow. +async function register(event) { + const body = await event.request.json().catch(() => ({})); + const redirectUris = Array.isArray(body.redirect_uris) ? body.redirect_uris : []; + return json(201, { + client_id: `puter-mcp-${crypto.randomUUID()}`, + client_id_issued_at: Math.floor(Date.now() / 1000), + redirect_uris: redirectUris, + grant_types: ['authorization_code'], + response_types: ['code'], + token_endpoint_auth_method: 'none', + }); +} + +/** Attach the OAuth routes to the (already-initialized) router. */ +export default function registerOAuthRoutes(router) { + router.get('/.well-known/oauth-authorization-server', authServerMetadata); + router.get('/.well-known/oauth-protected-resource', protectedResourceMetadata); + // Path-suffixed variants some clients probe (resource = the /mcp endpoint). + router.get('/.well-known/oauth-authorization-server/mcp', authServerMetadata); + router.get('/.well-known/oauth-protected-resource/mcp', protectedResourceMetadata); + router.post('/register', register); + router.get('/authorize', authorize); + router.get('/oauth/callback', callback); + router.post('/token', token); +} diff --git a/src/mcp-connector/wrangler.toml b/src/mcp-connector/wrangler.toml index 1b7f561c4..f629c1c48 100644 --- a/src/mcp-connector/wrangler.toml +++ b/src/mcp-connector/wrangler.toml @@ -12,7 +12,15 @@ compatibility_date = "2025-01-01" # statement"). Uploading the script as-is keeps it a sloppy-mode service worker. no_bundle = true -# Optional: point the server at a self-hosted Puter API instead of the public -# one. The router reads globalThis.puter_endpoint; bind it as a var to override. +# Optional vars (exposed as globals in the service-worker script): +# puter_endpoint - Puter API origin for fs/subdomain calls (default https://api.puter.com) +# puter_gui_origin - Puter GUI origin for the OAuth "Sign in with Puter" +# (authme) redirect (default https://puter.com) # [vars] # puter_endpoint = "https://api.puter.com" +# puter_gui_origin = "https://puter.com" + +# OAuth bridge secret — used to seal the stateless authorization-flow / code +# blobs (AES-GCM). REQUIRED in production; set it as a secret, do NOT commit it: +# wrangler secret put OAUTH_SECRET +# (Local `wrangler dev` falls back to an insecure default if unset.) diff --git a/src/puter-js/src/modules/FileSystem/operations/upload.js b/src/puter-js/src/modules/FileSystem/operations/upload.js index 7e48f5dd1..c99a48494 100644 --- a/src/puter-js/src/modules/FileSystem/operations/upload.js +++ b/src/puter-js/src/modules/FileSystem/operations/upload.js @@ -1,7 +1,7 @@ import path from '../../../lib/path.js'; import * as utils from '../../../lib/utils.js'; -import getAbsolutePathForApp from '../utils/getAbsolutePathForApp.js'; import { showUsageLimitDialog } from '../../../modules/UsageLimitDialog.js'; +import getAbsolutePathForApp from '../utils/getAbsolutePathForApp.js'; /* eslint-disable */ const MAX_THUMBNAIL_BYTES = 2 * 1024 * 1024; @@ -13,6 +13,7 @@ const SIGNED_BATCH_CHUNK_PIPELINE_CONCURRENCY = 4; const SIGNED_BATCH_FILE_UPLOAD_CONCURRENCY = 8; const SIGNED_MULTIPART_PART_UPLOAD_CONCURRENCY = 8; const SIGNED_BATCH_WRITE_UNAVAILABLE_STATUSES = new Set([404, 405, 501]); +const SIGNED_BATCH_SUPPORTED_ENVS = ['web', 'gui', 'app']; const isLikelyImageFile = (file) => { if ( ! file ) return false; @@ -605,6 +606,7 @@ const upload = async function (items, dirPath, options = {}) { const shouldAttemptSignedBatchWrite = ( + SIGNED_BATCH_SUPPORTED_ENVS.includes(puter.env) && !options.shortcutTo && (files.length > 0 || signedDirectories.length > 0) && signedBatchWriteAllowed