mirror of
https://github.com/HeyPuter/puter.git
synced 2026-07-08 08:12:15 +00:00
MCP Server (#3197)
* beginnings of mcp * oauth support * add workers and better prompt engineering
This commit is contained in:
committed by
GitHub
parent
acc4796b99
commit
5b8e41e66d
Generated
+1481
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
.wrangler/
|
||||
dist/
|
||||
.dev.vars
|
||||
*.log
|
||||
@@ -0,0 +1,233 @@
|
||||
# Puter MCP Connector (Cloudflare Workers)
|
||||
|
||||
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 static website hosting from an MCP client (Claude, Cursor, etc.).
|
||||
|
||||
The Worker stores **no credentials of its own**. Every request runs as the
|
||||
caller, authenticated either by a `Authorization: Bearer <token>` header or by an
|
||||
OAuth "Sign in with Puter" flow the Worker hosts itself (see
|
||||
[Authentication](#authentication)).
|
||||
|
||||
## Tools
|
||||
|
||||
### Filesystem
|
||||
| Tool | Description |
|
||||
| --- | --- |
|
||||
| `fs_read_file` | Read a file (UTF-8 or base64; optional offset/length). |
|
||||
| `fs_stat` | Stat a file or directory (size, type, timestamps, uid). |
|
||||
| `fs_write_file` | Create/overwrite a file (UTF-8 or base64 content). |
|
||||
| `fs_mkdir` | Create a directory (optionally creating missing parents). |
|
||||
| `fs_delete` | Delete a file or directory (recursive by default). |
|
||||
| `fs_readdir` | List the entries of a directory. |
|
||||
|
||||
### Hosting (static websites)
|
||||
Publishing a website in Puter means creating a hosting subdomain served at
|
||||
`https://<subdomain>.puter.site`, backed by a directory in your Puter filesystem.
|
||||
|
||||
| Tool | Description |
|
||||
| --- | --- |
|
||||
| `hosting_list` | List the caller's published websites (hosting subdomains). |
|
||||
| `hosting_get` | Get a website by its subdomain. |
|
||||
| `hosting_create` | Publish a website on a subdomain (optionally pointing at a `root_dir`). |
|
||||
| `hosting_update` | Re-point a website at a different `root_dir`. |
|
||||
| `hosting_delete` | Unpublish a website (deletes the subdomain, not the files). |
|
||||
|
||||
### Workers (serverless functions)
|
||||
Puter Workers are serverless JavaScript functions deployed from a file in your
|
||||
Puter filesystem. The worker file defines handlers on the global `router` object
|
||||
and has the full puter.js SDK available as `puter` — they are designed to be used
|
||||
**with puter.js and Puter authentication**. Update a worker by writing new code to
|
||||
its associated file (there is no separate update call).
|
||||
|
||||
| Tool | Description |
|
||||
| --- | --- |
|
||||
| `workers_create` | Deploy a worker from a JS file; returns its public URL. |
|
||||
| `workers_list` | List the caller's deployed workers. |
|
||||
| `workers_get` | Get a worker (name, URL, source file) by name. |
|
||||
| `workers_exec` | Call a worker over HTTP as the authenticated user. |
|
||||
| `workers_delete` | Undeploy a worker (leaves its source file in place). |
|
||||
|
||||
### Documentation
|
||||
| Tool | Description |
|
||||
| --- | --- |
|
||||
| `puter_docs_index` | Load the puter.js docs index (every topic + path) from `docs.puter.com/llms.txt`. |
|
||||
| `puter_docs_get` | Fetch a specific docs page as Markdown by topic path (e.g. `Workers/router`). |
|
||||
|
||||
### Paths
|
||||
Tools pass paths straight to puter.js, so the usual conventions apply: absolute
|
||||
(`/your-username/Desktop/file.txt`), home-relative (`~/Desktop/file.txt`), or
|
||||
relative (`Desktop/file.txt`, resolved against your home directory).
|
||||
|
||||
## How it works
|
||||
|
||||
This is a **fork of [`src/worker`](../worker)** — Puter's port of puter.js to a
|
||||
Cloudflare Worker runtime — with two changes:
|
||||
|
||||
1. **No `me.puter`.** The original worker creates a worker-owned puter instance
|
||||
from `globalThis.puter_auth`. That's removed: this connector holds no
|
||||
credentials of its own.
|
||||
2. **`user.puter` from the `Authorization` header.** The per-request puter
|
||||
instance is built from `Authorization: Bearer <token>` instead of the
|
||||
original `puter-auth` header.
|
||||
|
||||
Because it's the real port, the tools call genuine `puter.fs.*` and
|
||||
`puter.hosting.*` methods on a real puter.js instance (created via
|
||||
`init_puter_portable(token, origin, 'userPuter')`, which runs puter.js inside an
|
||||
isolated `with` context so concurrent requests don't share auth/cache state).
|
||||
|
||||
The MCP transport is **Streamable HTTP**: a single endpoint accepting JSON-RPC
|
||||
2.0 over `POST` (single message or batch array). The server is stateless. The
|
||||
MCP layer ([`src/mcp.js`](src/mcp.js)) is a small hand-rolled JSON-RPC dispatcher
|
||||
registered onto the forked router as routes.
|
||||
|
||||
### Build pipeline (same as `src/worker`)
|
||||
|
||||
`npm run build` does two steps:
|
||||
|
||||
1. **webpack** bundles [`src/index.js`](src/index.js) (the router +
|
||||
[`src/mcp.js`](src/mcp.js) + [`src/tools.js`](src/tools.js)) into
|
||||
`dist/webpackPreamplePart.js`.
|
||||
2. **`scripts/buildPreamble.mjs`** inlines `#include`s in
|
||||
[`template/puter-portable.template`](template/puter-portable.template) —
|
||||
pulling in `../puter-js/dist/puter.js` and the webpack bundle — to produce
|
||||
`dist/workerPreamble.js`, the deployable **service-worker-format** script.
|
||||
|
||||
> Requires `src/puter-js/dist/puter.js` to exist. Build puter.js first if needed
|
||||
> (`cd ../puter-js && npm run build`).
|
||||
|
||||
## Files
|
||||
|
||||
| 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 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). |
|
||||
|
||||
## Running locally
|
||||
|
||||
```bash
|
||||
cd src/mcp-connector
|
||||
npm install
|
||||
npm run dev # builds, then wrangler dev — serves on http://localhost:8787
|
||||
```
|
||||
|
||||
## Deploying
|
||||
|
||||
```bash
|
||||
npm run deploy # builds, then wrangler deploy
|
||||
```
|
||||
|
||||
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`.
|
||||
|
||||
If you use the OAuth flow (below), also set the sealing secret in production:
|
||||
|
||||
```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 <token>` header (or the `.mcpb` token field).
|
||||
|
||||
## Connecting a client
|
||||
|
||||
### Option A — Claude Code (OAuth, no token)
|
||||
|
||||
```bash
|
||||
claude mcp add --transport http puter https://puter-mcp.<your-subdomain>.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
|
||||
MCPB (e.g. Claude Desktop: Settings → Extensions → install from file), then fill
|
||||
in the two config fields it prompts for:
|
||||
|
||||
- **Server URL** — your deployed Worker, e.g. `https://puter-mcp.<your-subdomain>.workers.dev/`
|
||||
(or `http://127.0.0.1:8799/` for local `wrangler dev`).
|
||||
- **Puter Auth Token** — your personal token (stored as a secret).
|
||||
|
||||
Because the connector is a *remote* HTTP Worker but MCPB extensions run a *local*
|
||||
process, the bundle ships a tiny zero-dependency Node stdio↔HTTP proxy
|
||||
([`mcpb/server/index.cjs`](mcpb/server/index.cjs)) that forwards JSON-RPC to your
|
||||
Worker with the token attached as `Authorization: Bearer`.
|
||||
|
||||
Rebuild the bundle after changing the proxy or manifest:
|
||||
|
||||
```bash
|
||||
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 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):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"puter": {
|
||||
"url": "https://puter-mcp.<your-subdomain>.workers.dev/",
|
||||
"headers": { "Authorization": "Bearer YOUR_PUTER_TOKEN" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Quick smoke test with curl
|
||||
|
||||
```bash
|
||||
URL=http://localhost:8787
|
||||
TOKEN=your_puter_token
|
||||
|
||||
# initialize
|
||||
curl -s $URL -H 'content-type: application/json' -d '{
|
||||
"jsonrpc":"2.0","id":1,"method":"initialize",
|
||||
"params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}
|
||||
}'
|
||||
|
||||
# list tools
|
||||
curl -s $URL -H 'content-type: application/json' \
|
||||
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
|
||||
|
||||
# stat your home directory
|
||||
curl -s $URL -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' -d '{
|
||||
"jsonrpc":"2.0","id":3,"method":"tools/call",
|
||||
"params":{"name":"fs_stat","arguments":{"path":"~"}}
|
||||
}'
|
||||
|
||||
# write then read a file
|
||||
curl -s $URL -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' -d '{
|
||||
"jsonrpc":"2.0","id":4,"method":"tools/call",
|
||||
"params":{"name":"fs_write_file","arguments":{"path":"~/Desktop/hello.txt","content":"hi from MCP"}}
|
||||
}'
|
||||
|
||||
# list hosted websites
|
||||
curl -s $URL -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
|
||||
-d '{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"hosting_list","arguments":{}}}'
|
||||
```
|
||||
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"manifest_version": "0.3",
|
||||
"name": "puter-mcp-connector",
|
||||
"display_name": "Puter",
|
||||
"version": "0.1.0",
|
||||
"description": "Use your own Puter account's filesystem, website hosting, and serverless workers over MCP.",
|
||||
"long_description": "Connects an MCP client to a deployed Puter MCP Worker. Authenticates with your own Puter token (no shared credentials) and exposes tools to read/write/stat files, make directories, delete entries, host static websites, and deploy/run serverless Puter Workers (which are built with puter.js and Puter authentication). It also ships puter.js documentation tools so an agent can look up the exact API before writing worker or SDK code. The bundle runs a tiny local stdio<->HTTP proxy that forwards JSON-RPC to your Worker with your token attached.",
|
||||
"author": {
|
||||
"name": "Puter Technologies Inc.",
|
||||
"url": "https://puter.com"
|
||||
},
|
||||
"homepage": "https://puter.com",
|
||||
"documentation": "https://github.com/HeyPuter/puter/tree/main/src/mcp-connector",
|
||||
"license": "AGPL-3.0-only",
|
||||
"keywords": ["puter", "filesystem", "hosting", "subdomains", "workers", "serverless", "puter.js", "fs"],
|
||||
"server": {
|
||||
"type": "node",
|
||||
"entry_point": "server/index.cjs",
|
||||
"mcp_config": {
|
||||
"command": "node",
|
||||
"args": ["${__dirname}/server/index.cjs"],
|
||||
"env": {
|
||||
"PUTER_MCP_URL": "${user_config.server_url}",
|
||||
"PUTER_TOKEN": "${user_config.puter_token}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"tools_generated": false,
|
||||
"tools": [
|
||||
{ "name": "fs_read_file", "description": "Read a file (UTF-8 or base64; optional offset/length)." },
|
||||
{ "name": "fs_stat", "description": "Stat a file or directory (size, type, timestamps, uid)." },
|
||||
{ "name": "fs_write_file", "description": "Create or overwrite a file (UTF-8 or base64 content)." },
|
||||
{ "name": "fs_mkdir", "description": "Create a directory (optionally creating missing parents)." },
|
||||
{ "name": "fs_delete", "description": "Delete a file or directory (recursive by default)." },
|
||||
{ "name": "fs_readdir", "description": "List the entries of a directory." },
|
||||
{ "name": "hosting_list", "description": "List the caller's published websites (hosting subdomains, served at <subdomain>.puter.site)." },
|
||||
{ "name": "hosting_get", "description": "Get a published website by its subdomain." },
|
||||
{ "name": "hosting_create", "description": "Publish a static website on a subdomain (optionally pointing at a root_dir)." },
|
||||
{ "name": "hosting_update", "description": "Re-point a website at a different root directory." },
|
||||
{ "name": "hosting_delete", "description": "Unpublish a website (deletes the subdomain, not the files)." },
|
||||
{ "name": "workers_create", "description": "Deploy a serverless Puter Worker from a JS file (uses the global router + puter.js); returns its public URL. Update by writing to the same file." },
|
||||
{ "name": "workers_list", "description": "List the caller's deployed serverless workers." },
|
||||
{ "name": "workers_get", "description": "Get a deployed worker (name, URL, source file) by name." },
|
||||
{ "name": "workers_exec", "description": "Call a deployed worker over HTTP as the authenticated user." },
|
||||
{ "name": "workers_delete", "description": "Undeploy a worker (leaves its source file in place)." },
|
||||
{ "name": "puter_docs_index", "description": "Load the puter.js documentation index (topics + paths) from docs.puter.com/llms.txt." },
|
||||
{ "name": "puter_docs_get", "description": "Fetch a specific puter.js documentation page as Markdown by topic path (e.g. Workers/router)." }
|
||||
],
|
||||
"user_config": {
|
||||
"server_url": {
|
||||
"type": "string",
|
||||
"title": "Server URL",
|
||||
"description": "URL of your deployed Puter MCP Worker, e.g. https://puter-mcp.<your-subdomain>.workers.dev/ (use http://127.0.0.1:8799/ for local `wrangler dev`).",
|
||||
"required": true,
|
||||
"default": "https://puter-mcp.example.workers.dev/"
|
||||
},
|
||||
"puter_token": {
|
||||
"type": "string",
|
||||
"title": "Puter Auth Token",
|
||||
"description": "Your personal Puter token. Get it from a logged-in Puter browser tab: open the devtools console and run `puter.authToken`. Treated as a secret.",
|
||||
"required": true,
|
||||
"sensitive": true
|
||||
}
|
||||
},
|
||||
"compatibility": {
|
||||
"runtimes": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env node
|
||||
//
|
||||
// Puter MCP connector — local stdio <-> remote HTTP proxy.
|
||||
//
|
||||
// The Puter MCP server is a REMOTE Cloudflare Worker (Streamable HTTP). MCPB
|
||||
// extensions, however, launch a LOCAL server process that speaks MCP over
|
||||
// stdio. This tiny zero-dependency proxy bridges the two: it reads newline-
|
||||
// delimited JSON-RPC messages from stdin, POSTs each to the Worker with the
|
||||
// caller's Puter token attached as `Authorization: Bearer <token>`, and writes
|
||||
// the Worker's JSON response back to stdout.
|
||||
//
|
||||
// Config comes from environment variables (populated by the MCPB host from
|
||||
// user_config — see manifest.json):
|
||||
// PUTER_MCP_URL - URL of your deployed Worker (e.g. https://...workers.dev/)
|
||||
// PUTER_TOKEN - your personal Puter auth token
|
||||
//
|
||||
// Uses only Node built-ins so the bundle needs no node_modules.
|
||||
|
||||
'use strict';
|
||||
|
||||
const http = require('node:http');
|
||||
const https = require('node:https');
|
||||
const readline = require('node:readline');
|
||||
const { URL } = require('node:url');
|
||||
|
||||
const ENDPOINT = process.env.PUTER_MCP_URL;
|
||||
const TOKEN = process.env.PUTER_TOKEN || '';
|
||||
|
||||
const logErr = (...parts) => process.stderr.write(`[puter-mcp] ${parts.join(' ')}\n`);
|
||||
|
||||
if (!ENDPOINT) {
|
||||
logErr('FATAL: PUTER_MCP_URL is not set. Configure the "Server URL" in the extension settings.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let endpointUrl;
|
||||
try {
|
||||
endpointUrl = new URL(ENDPOINT);
|
||||
} catch {
|
||||
logErr(`FATAL: PUTER_MCP_URL is not a valid URL: ${ENDPOINT}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const transport = endpointUrl.protocol === 'https:' ? https : http;
|
||||
|
||||
// POST one raw JSON-RPC line to the Worker. Resolves to {status, text} or {error}.
|
||||
function forward(rawLine) {
|
||||
return new Promise((resolve) => {
|
||||
const body = Buffer.from(rawLine, 'utf8');
|
||||
const headers = {
|
||||
'content-type': 'application/json',
|
||||
accept: 'application/json',
|
||||
'content-length': body.length,
|
||||
};
|
||||
if (TOKEN) headers.authorization = `Bearer ${TOKEN}`;
|
||||
|
||||
const req = transport.request(
|
||||
endpointUrl,
|
||||
{ method: 'POST', headers },
|
||||
(res) => {
|
||||
const chunks = [];
|
||||
res.on('data', (c) => chunks.push(c));
|
||||
res.on('end', () =>
|
||||
resolve({ status: res.statusCode, text: Buffer.concat(chunks).toString('utf8') }),
|
||||
);
|
||||
},
|
||||
);
|
||||
req.on('error', (error) => resolve({ error }));
|
||||
req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
function writeMessage(text) {
|
||||
process.stdout.write(text.endsWith('\n') ? text : `${text}\n`);
|
||||
}
|
||||
|
||||
const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
|
||||
|
||||
// Requests are forwarded concurrently; don't exit on stdin EOF until every
|
||||
// in-flight request has resolved (otherwise piped input loses its responses).
|
||||
let pending = 0;
|
||||
let stdinClosed = false;
|
||||
const maybeExit = () => {
|
||||
if (stdinClosed && pending === 0) process.exit(0);
|
||||
};
|
||||
|
||||
async function handleLine(raw) {
|
||||
// Best-effort parse only to recover an id for error reporting.
|
||||
let id;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && !Array.isArray(parsed)) id = parsed.id;
|
||||
} catch {
|
||||
// Not our problem to validate — let the server reject it.
|
||||
}
|
||||
|
||||
const res = await forward(raw);
|
||||
|
||||
if (res.error) {
|
||||
logErr(`request failed: ${res.error.message}`);
|
||||
// Only requests (with an id) expect a response.
|
||||
if (id !== undefined && id !== null) {
|
||||
writeMessage(
|
||||
JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
error: { code: -32000, message: `Cannot reach Puter MCP server: ${res.error.message}` },
|
||||
}),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 202 (notification ack) or an empty body => nothing to relay.
|
||||
if (res.status === 202 || !res.text) return;
|
||||
writeMessage(res.text);
|
||||
}
|
||||
|
||||
rl.on('line', (line) => {
|
||||
const raw = line.trim();
|
||||
if (!raw) return;
|
||||
pending += 1;
|
||||
handleLine(raw).finally(() => {
|
||||
pending -= 1;
|
||||
maybeExit();
|
||||
});
|
||||
});
|
||||
|
||||
rl.on('close', () => {
|
||||
stdinClosed = true;
|
||||
maybeExit();
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "puter-mcp-connector",
|
||||
"version": "0.1.0",
|
||||
"description": "Cloudflare Worker MCP server for Puter filesystem and subdomain operations, running the real puter.js port and authenticated per-caller with their own Puter token.",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"main": "dist/workerPreamble.js",
|
||||
"scripts": {
|
||||
"build": "webpack --config webpack.config.cjs --mode production && node ./scripts/buildPreamble.mjs",
|
||||
"dev": "npm run build && wrangler dev",
|
||||
"deploy": "npm run build && wrangler deploy",
|
||||
"pack:mcpb": "npx -y @anthropic-ai/mcpb@latest pack mcpb puter-mcp-connector.mcpb"
|
||||
},
|
||||
"devDependencies": {
|
||||
"terser-webpack-plugin": "^5.3.14",
|
||||
"webpack": "^5.88.2",
|
||||
"webpack-cli": "^5.1.1",
|
||||
"wrangler": "^3.90.0"
|
||||
},
|
||||
"author": "Puter Technologies Inc.",
|
||||
"license": "AGPL-3.0-only"
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { execSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
// Forked from src/worker/scripts/buildPreamble.mjs. Lives at src/mcp-connector,
|
||||
// a sibling of src/puter-js, so puter-js is at ../puter-js (same as the original).
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const workerDir = path.resolve(scriptDir, '..');
|
||||
const templatePath = path.join(workerDir, 'template', 'puter-portable.template');
|
||||
const outputDir = path.join(workerDir, 'dist');
|
||||
const outputPath = path.join(outputDir, 'workerPreamble.js');
|
||||
|
||||
// Build a version stamp: puter-js version + short git SHA
|
||||
const puterJsPkg = JSON.parse(
|
||||
await readFile(path.resolve(workerDir, '../puter-js/package.json'), 'utf-8'),
|
||||
);
|
||||
let gitSha = 'unknown';
|
||||
try {
|
||||
gitSha = execSync('git rev-parse --short HEAD', { encoding: 'utf-8' }).trim();
|
||||
} catch { /* not in a git repo — keep "unknown" */ }
|
||||
const preambleVersion = `${puterJsPkg.version}+${gitSha}`;
|
||||
|
||||
const inlineIncludes = async (filePath) => {
|
||||
const fileContents = await readFile(filePath, 'utf-8');
|
||||
const lines = fileContents.split('\n');
|
||||
const expandedLines = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const includeMatch = /^([ \t]*)#include "([^"]+)"$/.exec(line);
|
||||
if (!includeMatch) {
|
||||
expandedLines.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
const [, indent, relativePath] = includeMatch;
|
||||
const includedPath = path.resolve(path.dirname(filePath), relativePath);
|
||||
const includedContents = await inlineIncludes(includedPath);
|
||||
for (const includedLine of includedContents.split('\n')) {
|
||||
expandedLines.push(
|
||||
includedLine.length > 0
|
||||
? `${indent}${includedLine}`
|
||||
: includedLine,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return expandedLines.join('\n');
|
||||
};
|
||||
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
const versionBanner = `var __PUTER_PREAMBLE_VERSION__ = ${JSON.stringify(preambleVersion)};\n`;
|
||||
const preambleSource = await inlineIncludes(templatePath);
|
||||
await writeFile(outputPath, versionBanner + preambleSource);
|
||||
console.log(`Wrote ${outputPath} (puter-js ${preambleVersion})`);
|
||||
@@ -0,0 +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 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);
|
||||
@@ -0,0 +1,214 @@
|
||||
// Minimal MCP (Model Context Protocol) server over the Streamable HTTP transport,
|
||||
// wired onto the forked Puter worker router.
|
||||
//
|
||||
// `registerMcpRoutes(router)` attaches:
|
||||
// POST / and POST /mcp -> MCP JSON-RPC endpoint
|
||||
// GET /, /mcp, /health -> discovery / health
|
||||
//
|
||||
// Tool handlers run against the caller's REAL puter.js instance, which the
|
||||
// router builds from the Authorization: Bearer header (event.user.puter).
|
||||
|
||||
import { TOOL_MAP, listTools, asText } from './tools.js';
|
||||
|
||||
const PROTOCOL_VERSION = '2025-06-18';
|
||||
const SUPPORTED_PROTOCOL_VERSIONS = new Set(['2025-06-18', '2025-03-26', '2024-11-05']);
|
||||
|
||||
const SERVER_INFO = {
|
||||
name: 'puter-mcp',
|
||||
title: 'Puter MCP Server',
|
||||
version: '0.1.0',
|
||||
};
|
||||
|
||||
// JSON-RPC error codes.
|
||||
const PARSE_ERROR = -32700;
|
||||
const INVALID_REQUEST = -32600;
|
||||
const METHOD_NOT_FOUND = -32601;
|
||||
const INVALID_PARAMS = -32602;
|
||||
const INTERNAL_ERROR = -32603;
|
||||
|
||||
function rpcResult(id, result) {
|
||||
return { jsonrpc: '2.0', id, result };
|
||||
}
|
||||
|
||||
function rpcError(id, code, message, data) {
|
||||
const error = { code, message };
|
||||
if (data !== undefined) error.data = data;
|
||||
return { jsonrpc: '2.0', id: id ?? null, error };
|
||||
}
|
||||
|
||||
function toolError(message) {
|
||||
return { content: [{ type: 'text', text: message }], isError: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a single JSON-RPC message. Returns a response object, or `null` for
|
||||
* notifications (which must not produce a response).
|
||||
*
|
||||
* @param {object} msg The parsed JSON-RPC message.
|
||||
* @param {object|undefined} userPuter The caller's puter instance (or undefined if unauthenticated).
|
||||
*/
|
||||
async function handleMessage(msg, userPuter) {
|
||||
if (!msg || typeof msg !== 'object' || msg.jsonrpc !== '2.0' || typeof msg.method !== 'string') {
|
||||
return rpcError(msg?.id ?? null, INVALID_REQUEST, 'Invalid JSON-RPC request');
|
||||
}
|
||||
|
||||
const { id, method, params } = msg;
|
||||
const isNotification = id === undefined;
|
||||
|
||||
try {
|
||||
switch (method) {
|
||||
case 'initialize': {
|
||||
const requested = params?.protocolVersion;
|
||||
const protocolVersion = SUPPORTED_PROTOCOL_VERSIONS.has(requested)
|
||||
? requested
|
||||
: PROTOCOL_VERSION;
|
||||
return rpcResult(id, {
|
||||
protocolVersion,
|
||||
capabilities: { tools: { listChanged: false } },
|
||||
serverInfo: SERVER_INFO,
|
||||
instructions:
|
||||
'Puter MCP server. Authenticate with your own Puter token via the ' +
|
||||
'Authorization: Bearer <token> header. Provides filesystem tools (fs_*), ' +
|
||||
'static website hosting tools (hosting_*, served at <subdomain>.puter.site), ' +
|
||||
'serverless worker tools (workers_*), and puter.js documentation tools ' +
|
||||
'(puter_docs_*). Puter Workers are built WITH puter.js and Puter authentication: ' +
|
||||
'before writing worker code, call puter_docs_get with path "Workers/router".',
|
||||
});
|
||||
}
|
||||
|
||||
case 'ping':
|
||||
return rpcResult(id, {});
|
||||
|
||||
case 'notifications/initialized':
|
||||
case 'notifications/cancelled':
|
||||
case 'notifications/roots/list_changed':
|
||||
return null; // notifications get no response
|
||||
|
||||
case 'tools/list':
|
||||
return rpcResult(id, { tools: listTools() });
|
||||
|
||||
case 'tools/call':
|
||||
return await handleToolCall(id, params, userPuter);
|
||||
|
||||
default:
|
||||
if (isNotification) return null;
|
||||
return rpcError(id, METHOD_NOT_FOUND, `Method not found: ${method}`);
|
||||
}
|
||||
} catch (err) {
|
||||
if (isNotification) return null;
|
||||
return rpcError(id, INTERNAL_ERROR, err?.message || 'Internal error');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToolCall(id, params, userPuter) {
|
||||
const name = params?.name;
|
||||
const tool = name && TOOL_MAP.get(name);
|
||||
if (!tool) {
|
||||
return rpcError(id, INVALID_PARAMS, `Unknown tool: ${name}`);
|
||||
}
|
||||
|
||||
if (!userPuter) {
|
||||
// Surface auth problems as a tool error so MCP clients display it inline.
|
||||
return rpcResult(id, toolError('Missing Authorization: Bearer <puter-token> header.'));
|
||||
}
|
||||
|
||||
const args = params.arguments || {};
|
||||
try {
|
||||
const value = await tool.handler(userPuter, args);
|
||||
|
||||
// Handlers may return { text } to emit raw text, otherwise we JSON-encode.
|
||||
const text = value && typeof value === 'object' && typeof value.text === 'string'
|
||||
? value.text
|
||||
: asText(value);
|
||||
const result = { content: [{ type: 'text', text }] };
|
||||
if (value && typeof value === 'object' && value._meta) result._meta = value._meta;
|
||||
return rpcResult(id, result);
|
||||
} catch (err) {
|
||||
return rpcResult(id, toolError(formatPuterError(err)));
|
||||
}
|
||||
}
|
||||
|
||||
// puter.js rejects with various shapes (Error, {error}, {message,code}, string).
|
||||
function formatPuterError(err) {
|
||||
if (!err) return 'Tool execution failed';
|
||||
if (typeof err === 'string') return err;
|
||||
if (err.message) return err.code ? `${err.message} (${err.code})` : err.message;
|
||||
if (err.error) return typeof err.error === 'string' ? err.error : (err.error.message || JSON.stringify(err.error));
|
||||
try {
|
||||
return JSON.stringify(err);
|
||||
} catch {
|
||||
return 'Tool execution failed';
|
||||
}
|
||||
}
|
||||
|
||||
function jsonResponse(body) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// MCP Streamable HTTP POST handler (one request object or a batch array).
|
||||
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();
|
||||
} catch {
|
||||
return jsonResponse(rpcError(null, PARSE_ERROR, 'Invalid JSON body'));
|
||||
}
|
||||
|
||||
if (Array.isArray(payload)) {
|
||||
if (payload.length === 0) {
|
||||
return jsonResponse(rpcError(null, INVALID_REQUEST, 'Empty batch'));
|
||||
}
|
||||
const responses = (await Promise.all(payload.map((m) => handleMessage(m, userPuter)))).filter(Boolean);
|
||||
if (responses.length === 0) return new Response(null, { status: 202 });
|
||||
return jsonResponse(responses);
|
||||
}
|
||||
|
||||
const response = await handleMessage(payload, userPuter);
|
||||
if (response === null) return new Response(null, { status: 202 }); // notification
|
||||
return jsonResponse(response);
|
||||
}
|
||||
|
||||
// Discovery / health.
|
||||
function mcpInfo() {
|
||||
return {
|
||||
name: 'puter-mcp',
|
||||
description:
|
||||
'MCP server for Puter filesystem, static website hosting, and serverless worker operations. POST JSON-RPC to ' +
|
||||
'this endpoint with your Puter token in the Authorization: Bearer <token> header.',
|
||||
transport: 'streamable-http',
|
||||
tools: listTools().map((t) => t.name),
|
||||
};
|
||||
}
|
||||
|
||||
/** Attach the MCP routes to the (already-initialized) router. */
|
||||
export default function registerMcpRoutes(router) {
|
||||
router.post('/', mcpPost);
|
||||
router.post('/mcp', mcpPost);
|
||||
router.get('/', mcpInfo);
|
||||
router.get('/mcp', mcpInfo);
|
||||
router.get('/health', mcpInfo);
|
||||
}
|
||||
@@ -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=<worker>/oauth/callback?flow=…
|
||||
// (user logs in + approves on puter.com; Puter 302s back with ?token=…)
|
||||
// puter → GET /oauth/callback -> 302 to <client redirect_uri>?code=…&state=…
|
||||
// client → POST /token -> { access_token: <puter token> }
|
||||
// client → MCP calls with Authorization: Bearer <puter token> (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);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
// Forked from src/worker/src/s2w-router.js for the Puter MCP server.
|
||||
//
|
||||
// Differences from the original worker router:
|
||||
// 1. There is NO `globalThis.me` / worker-owned puter instance. This server
|
||||
// holds no credentials of its own — every request runs as the caller.
|
||||
// 2. The per-request puter instance (`event.user.puter`) is generated from the
|
||||
// standard `Authorization: Bearer <token>` header instead of `puter-auth`.
|
||||
|
||||
const escapeRegex = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
|
||||
const buildRouteMatcher = (route) => {
|
||||
let pattern = '^';
|
||||
const paramNames = [];
|
||||
|
||||
for (let index = 0; index < route.length; index += 1) {
|
||||
const char = route[index];
|
||||
if (char === ':' || char === '*') {
|
||||
let name = '';
|
||||
let offset = index + 1;
|
||||
while (offset < route.length) {
|
||||
const nextChar = route[offset];
|
||||
if (!/[A-Za-z0-9_]/.test(nextChar)) {
|
||||
break;
|
||||
}
|
||||
name += nextChar;
|
||||
offset += 1;
|
||||
}
|
||||
|
||||
if (name.length === 0) {
|
||||
pattern += escapeRegex(char);
|
||||
continue;
|
||||
}
|
||||
|
||||
paramNames.push(name);
|
||||
pattern += char === ':' ? '([^/]+)' : '(.*)';
|
||||
index = offset - 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
pattern += escapeRegex(char);
|
||||
}
|
||||
|
||||
pattern += '$';
|
||||
const regex = new RegExp(pattern);
|
||||
return (pathname) => {
|
||||
const matches = regex.exec(pathname);
|
||||
if (!matches) return false;
|
||||
|
||||
const params = {};
|
||||
for (let index = 0; index < paramNames.length; index += 1) {
|
||||
params[paramNames[index]] = matches[index + 1];
|
||||
}
|
||||
return { params };
|
||||
};
|
||||
};
|
||||
|
||||
// Error responses as JSON (with CORS) so clients never get a non-JSON body they
|
||||
// might try to parse — notably MCP OAuth discovery probes against unknown paths.
|
||||
const jsonError = (status, message) =>
|
||||
new Response(JSON.stringify({ error: { code: status, message } }), {
|
||||
status,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
},
|
||||
});
|
||||
|
||||
// Pull the bearer token out of the Authorization header.
|
||||
const getBearerToken = (request) => {
|
||||
const header = request.headers.get('authorization');
|
||||
if (!header) return null;
|
||||
const match = /^Bearer\s+(.+)$/i.exec(header.trim());
|
||||
return match ? match[1].trim() : null;
|
||||
};
|
||||
|
||||
function initS2w () {
|
||||
const router = {
|
||||
routing: true,
|
||||
handleCors: true,
|
||||
map: new Map(),
|
||||
custom(eventName, route, eventListener) {
|
||||
const matchExp = buildRouteMatcher(route);
|
||||
if (!this.map.has(eventName)) {
|
||||
this.map.set(eventName, [[matchExp, eventListener]]);
|
||||
return;
|
||||
}
|
||||
this.map.get(eventName).push([matchExp, eventListener]);
|
||||
},
|
||||
get(...args) {
|
||||
this.custom('GET', ...args);
|
||||
},
|
||||
post(...args) {
|
||||
this.custom('POST', ...args);
|
||||
},
|
||||
options(...args) {
|
||||
this.custom('OPTIONS', ...args);
|
||||
},
|
||||
put(...args) {
|
||||
this.custom('PUT', ...args);
|
||||
},
|
||||
delete(...args) {
|
||||
this.custom('DELETE', ...args);
|
||||
},
|
||||
async handleOptions(request) {
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET,HEAD,POST,OPTIONS',
|
||||
'Access-Control-Max-Age': '86400',
|
||||
};
|
||||
if (
|
||||
request.headers.get('Origin') !== null &&
|
||||
request.headers.get('Access-Control-Request-Method') !== null &&
|
||||
request.headers.get('Access-Control-Request-Headers') !== null
|
||||
) {
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
...corsHeaders,
|
||||
'Access-Control-Allow-Headers':
|
||||
request.headers.get(
|
||||
'Access-Control-Request-Headers',
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
Allow: 'GET, HEAD, POST, OPTIONS',
|
||||
},
|
||||
});
|
||||
},
|
||||
async route(event) {
|
||||
// Generate the caller's puter instance from the Authorization header.
|
||||
// No `me`/worker-owned instance: this server is purely a pass-through
|
||||
// for the caller's own token.
|
||||
const token = getBearerToken(event.request);
|
||||
if (token) {
|
||||
event.requestor = {
|
||||
puter: init_puter_portable(
|
||||
token,
|
||||
globalThis.puter_endpoint || 'https://api.puter.com',
|
||||
'userPuter',
|
||||
),
|
||||
};
|
||||
event.user = event.requestor;
|
||||
}
|
||||
|
||||
const mappings = this.map.get(event.request.method);
|
||||
if (this.handleCors && event.request.method === 'OPTIONS' && !mappings) {
|
||||
return this.handleOptions(event.request);
|
||||
}
|
||||
if (!mappings) {
|
||||
// JSON (not plain text) so clients that probe unknown paths —
|
||||
// e.g. an MCP client's OAuth discovery — can parse the body
|
||||
// instead of throwing a JSON syntax error on it.
|
||||
return jsonError(404, `No routes for request method ${event.request.method}`);
|
||||
}
|
||||
|
||||
const url = new URL(event.request.url);
|
||||
try {
|
||||
for (const mapping of mappings) {
|
||||
const results = mapping[0](url.pathname);
|
||||
if (!results) continue;
|
||||
|
||||
event.params = results.params;
|
||||
let response = await mapping[1](event);
|
||||
if (!(response instanceof Response)) {
|
||||
try {
|
||||
if (
|
||||
response instanceof Blob ||
|
||||
response instanceof ArrayBuffer ||
|
||||
response instanceof Uint8Array.__proto__ ||
|
||||
response instanceof ReadableStream ||
|
||||
response instanceof URLSearchParams ||
|
||||
typeof response === 'string'
|
||||
) {
|
||||
response = new Response(response);
|
||||
} else {
|
||||
response = new Response(JSON.stringify(response), {
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
throw new Error(
|
||||
'Returned response by handler was neither a Response object nor an object which can implicitly be converted into a Response object',
|
||||
);
|
||||
}
|
||||
}
|
||||
if (
|
||||
this.handleCors &&
|
||||
!response.headers.has('access-control-allow-origin')
|
||||
) {
|
||||
response.headers.set('Access-Control-Allow-Origin', '*');
|
||||
}
|
||||
return response;
|
||||
}
|
||||
} catch (error) {
|
||||
return jsonError(500, String(error && error.message ? error.message : error));
|
||||
}
|
||||
|
||||
// No matching route for this path. JSON 404 (see note above) — this
|
||||
// is also what OAuth/well-known discovery probes will hit.
|
||||
return jsonError(404, 'Path not found');
|
||||
},
|
||||
};
|
||||
|
||||
globalThis.router = router;
|
||||
self.addEventListener('fetch', (event) => {
|
||||
if (!router.routing) {
|
||||
return false;
|
||||
}
|
||||
event.respondWith(router.route(event));
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export default initS2w;
|
||||
@@ -0,0 +1,499 @@
|
||||
// MCP tool definitions + handlers.
|
||||
//
|
||||
// Handlers receive the caller's REAL puter.js instance (the same `puter` object
|
||||
// the in-repo worker exposes, created from the Authorization header) and call
|
||||
// genuine puter.fs.* / puter.hosting.* methods.
|
||||
//
|
||||
// Each entry has a JSON-Schema `inputSchema` (advertised via tools/list) and a
|
||||
// `handler(puter, args)`; the MCP layer wraps the return value into `content`.
|
||||
|
||||
/** Decode a base64 string into a Uint8Array (Workers have atob). */
|
||||
function base64ToBytes(b64) {
|
||||
const binary = atob(b64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/** Encode bytes to base64 (btoa is byte-unsafe for >0xFF, so chunk over a Uint8Array). */
|
||||
function bytesToBase64(bytes) {
|
||||
let binary = '';
|
||||
const chunk = 0x8000;
|
||||
for (let i = 0; i < bytes.length; i += chunk) {
|
||||
binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
/** Normalize puter.fs.read output (a Blob/Response-like) into text or base64. */
|
||||
async function decodeReadResult(result, encoding) {
|
||||
let bytes;
|
||||
if (result instanceof Blob) {
|
||||
bytes = new Uint8Array(await result.arrayBuffer());
|
||||
} else if (result instanceof ArrayBuffer) {
|
||||
bytes = new Uint8Array(result);
|
||||
} else if (result instanceof Uint8Array) {
|
||||
bytes = result;
|
||||
} else if (typeof result === 'string') {
|
||||
if (encoding === 'base64') {
|
||||
bytes = new TextEncoder().encode(result);
|
||||
} else {
|
||||
return { content: result, encoding: 'utf8', bytes: result.length };
|
||||
}
|
||||
} else if (result && typeof result.arrayBuffer === 'function') {
|
||||
bytes = new Uint8Array(await result.arrayBuffer());
|
||||
} else {
|
||||
// Fallback: stringify whatever we got.
|
||||
const text = typeof result === 'object' ? JSON.stringify(result) : String(result);
|
||||
return { content: text, encoding: 'utf8', bytes: text.length };
|
||||
}
|
||||
|
||||
if (encoding === 'base64') {
|
||||
return { content: bytesToBase64(bytes), encoding: 'base64', bytes: bytes.length };
|
||||
}
|
||||
return { content: new TextDecoder().decode(bytes), encoding: 'utf8', bytes: bytes.length };
|
||||
}
|
||||
|
||||
// ----- puter.js documentation fetching -------------------------------------
|
||||
// The puter_docs_* tools pull authoritative docs straight from docs.puter.com so
|
||||
// an agent writes correct worker / SDK code instead of guessing the API.
|
||||
const DOCS_HOST = 'docs.puter.com';
|
||||
const DOCS_INDEX_URL = `https://${DOCS_HOST}/llms.txt`;
|
||||
|
||||
/** Resolve a docs topic/path to a canonical https://docs.puter.com/.../index.md URL. */
|
||||
function resolveDocUrl(pathOrTopic) {
|
||||
let p = String(pathOrTopic || '').trim();
|
||||
if (!p || p === 'llms' || p === 'llms.txt') return DOCS_INDEX_URL;
|
||||
// Accept a full URL, but only on the docs host (avoid SSRF to arbitrary hosts).
|
||||
if (/^https?:\/\//i.test(p)) {
|
||||
const u = new URL(p);
|
||||
if (u.hostname !== DOCS_HOST) {
|
||||
throw new Error(`Only ${DOCS_HOST} documentation URLs are allowed.`);
|
||||
}
|
||||
return u.toString();
|
||||
}
|
||||
// Normalize a topic slug like "Workers/router" or "Workers/router/index.md".
|
||||
p = p.replace(/^\/+|\/+$/g, '').replace(/\/index\.md$/i, '').replace(/\.md$/i, '');
|
||||
if (!p) return DOCS_INDEX_URL;
|
||||
return `https://${DOCS_HOST}/${p}/index.md`;
|
||||
}
|
||||
|
||||
/** Fetch a docs page as text, throwing a readable error on failure. */
|
||||
async function fetchDocText(url) {
|
||||
const resp = await fetch(url, { headers: { accept: 'text/markdown, text/plain, */*' } });
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Failed to fetch Puter docs (HTTP ${resp.status}) from ${url}`);
|
||||
}
|
||||
return resp.text();
|
||||
}
|
||||
|
||||
export const TOOLS = [
|
||||
// ----- filesystem ------------------------------------------------------
|
||||
{
|
||||
name: 'fs_read_file',
|
||||
description:
|
||||
'Read the contents of a file in Puter. Returns UTF-8 text by default; ' +
|
||||
'pass encoding="base64" for binary files. Supports optional byte offset/length. ' +
|
||||
'Equivalent to PuterJS puter.fs.read(path).',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: 'File path. Absolute (/user/...), ~/relative, or relative to home.' },
|
||||
encoding: { type: 'string', enum: ['utf8', 'base64'], default: 'utf8' },
|
||||
offset: { type: 'integer', minimum: 0, description: 'Byte offset to start reading from.' },
|
||||
length: { type: 'integer', minimum: 1, description: 'Maximum number of bytes to read.' },
|
||||
},
|
||||
required: ['path'],
|
||||
},
|
||||
async handler(puter, { path, encoding = 'utf8', offset, length }) {
|
||||
const options = {};
|
||||
if (offset != null) options.offset = offset;
|
||||
if (length != null) options.byte_count = length;
|
||||
const result = await puter.fs.read(path, options);
|
||||
const { content, encoding: enc, bytes } = await decodeReadResult(result, encoding);
|
||||
return { _meta: { encoding: enc, bytes }, text: content };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'fs_stat',
|
||||
description: 'Get metadata (name, size, type, timestamps, uid) for a file or directory in Puter. Equivalent to PuterJS puter.fs.stat(path).',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Path to a file or directory.' },
|
||||
return_size: { type: 'boolean', default: true, description: 'Compute size for directories.' },
|
||||
},
|
||||
required: ['path'],
|
||||
},
|
||||
async handler(puter, { path, return_size }) {
|
||||
return puter.fs.stat(path, { returnSize: return_size !== false });
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'fs_write_file',
|
||||
description:
|
||||
'Write (create or overwrite) a file in Puter. Provide content as UTF-8 text, ' +
|
||||
'or set encoding="base64" to write binary data. Equivalent to PuterJS puter.fs.write(path, data).',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Destination file path.' },
|
||||
content: { type: 'string', description: 'File contents (UTF-8, or base64 if encoding=base64).' },
|
||||
encoding: { type: 'string', enum: ['utf8', 'base64'], default: 'utf8' },
|
||||
overwrite: { type: 'boolean', default: true, description: 'Overwrite an existing file.' },
|
||||
create_missing_parents: {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Create missing parent directories.',
|
||||
},
|
||||
dedupe_name: {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Auto-rename instead of overwriting if the file exists.',
|
||||
},
|
||||
},
|
||||
required: ['path', 'content'],
|
||||
},
|
||||
async handler(puter, { path, content, encoding = 'utf8', overwrite = true, create_missing_parents = false, dedupe_name = false }) {
|
||||
const data = encoding === 'base64'
|
||||
? new Blob([base64ToBytes(content)])
|
||||
: content;
|
||||
return puter.fs.write(path, data, {
|
||||
overwrite,
|
||||
dedupeName: dedupe_name,
|
||||
createMissingParents: create_missing_parents,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'fs_mkdir',
|
||||
description: 'Create a directory in Puter (optionally creating missing parents). Equivalent to PuterJS puter.fs.mkdir(path).',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Directory path to create.' },
|
||||
create_missing_parents: {
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description: 'Create intermediate directories as needed.',
|
||||
},
|
||||
},
|
||||
required: ['path'],
|
||||
},
|
||||
async handler(puter, { path, create_missing_parents }) {
|
||||
return puter.fs.mkdir(path, { createMissingParents: create_missing_parents !== false });
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'fs_delete',
|
||||
description: 'Delete a file or directory in Puter. Directories are removed recursively by default. Equivalent to PuterJS puter.fs.delete(path).',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: {
|
||||
description: 'Path (string) or list of paths to delete.',
|
||||
anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }],
|
||||
},
|
||||
recursive: { type: 'boolean', default: true, description: 'Recurse into directories.' },
|
||||
},
|
||||
required: ['path'],
|
||||
},
|
||||
async handler(puter, { path, recursive }) {
|
||||
await puter.fs.delete(path, { recursive: recursive !== false });
|
||||
return { success: true, deleted: Array.isArray(path) ? path : [path] };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'fs_readdir',
|
||||
description: 'List the entries (files and subdirectories) of a directory in Puter. Equivalent to PuterJS puter.fs.readdir(path).',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Directory path to list.' },
|
||||
},
|
||||
required: ['path'],
|
||||
},
|
||||
async handler(puter, { path }) {
|
||||
return puter.fs.readdir(path);
|
||||
},
|
||||
},
|
||||
|
||||
// ----- hosting / static websites (puter.hosting) -----------------------
|
||||
// In Puter, "hosting" means publishing a static website. Each website lives
|
||||
// at a subdomain of puter.site (e.g. "my-site" -> https://my-site.puter.site)
|
||||
// and is backed by a directory in the user's Puter filesystem. These tools
|
||||
// are how an agent puts files online: write the site's files with fs_write_file,
|
||||
// then hosting_create a subdomain pointing at that directory.
|
||||
{
|
||||
name: 'hosting_list',
|
||||
description:
|
||||
'List all websites (hosting subdomains) the authenticated Puter user has published. ' +
|
||||
'Each entry includes the subdomain (served at https://<subdomain>.puter.site) and the ' +
|
||||
'Puter directory it is hosted from. Use this to discover existing sites before creating ' +
|
||||
'or updating one. Equivalent to PuterJS puter.hosting.list().',
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
async handler(puter) {
|
||||
return puter.hosting.list();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'hosting_get',
|
||||
description:
|
||||
'Get a single published website (hosting subdomain) by its subdomain label, including ' +
|
||||
'the Puter directory it serves from. The live site is reachable at ' +
|
||||
'https://<subdomain>.puter.site. Equivalent to PuterJS puter.hosting.get(subdomain).',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
subdomain: { type: 'string', description: 'The subdomain label, e.g. "my-site" (without the .puter.site suffix).' },
|
||||
},
|
||||
required: ['subdomain'],
|
||||
},
|
||||
async handler(puter, { subdomain }) {
|
||||
return puter.hosting.get(subdomain);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'hosting_create',
|
||||
description:
|
||||
'Publish a new static website by creating a hosting subdomain. The site goes live at ' +
|
||||
'https://<subdomain>.puter.site. Point it at a Puter directory (root_dir) to serve that ' +
|
||||
"directory's files (e.g. an index.html) as a website; omit root_dir to reserve the " +
|
||||
'subdomain and attach a directory later with hosting_update. Typical flow: fs_mkdir a ' +
|
||||
'directory, fs_write_file your index.html into it, then hosting_create with that root_dir. ' +
|
||||
'Equivalent to PuterJS puter.hosting.create(subdomain, root_dir).',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
subdomain: {
|
||||
type: 'string',
|
||||
description: 'Subdomain label for the site (lowercase letters, digits, hyphens; max 64 chars). The site will be served at https://<subdomain>.puter.site.',
|
||||
},
|
||||
root_dir: {
|
||||
type: 'string',
|
||||
description: 'Puter directory path whose files are served as the website (e.g. "/me/my-site"). Omit to create the subdomain without content for now.',
|
||||
},
|
||||
},
|
||||
required: ['subdomain'],
|
||||
},
|
||||
async handler(puter, { subdomain, root_dir }) {
|
||||
return root_dir
|
||||
? puter.hosting.create(subdomain, root_dir)
|
||||
: puter.hosting.create(subdomain);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'hosting_update',
|
||||
description:
|
||||
'Re-point an existing website (hosting subdomain) at a different Puter directory, changing ' +
|
||||
'which files https://<subdomain>.puter.site serves. Use this to attach content to a bare ' +
|
||||
'subdomain or to swap the served directory. Equivalent to PuterJS puter.hosting.update(subdomain, root_dir).',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
subdomain: { type: 'string', description: 'The subdomain label of the site to update.' },
|
||||
root_dir: {
|
||||
type: 'string',
|
||||
description: 'New Puter directory path to serve the website from.',
|
||||
},
|
||||
},
|
||||
required: ['subdomain', 'root_dir'],
|
||||
},
|
||||
async handler(puter, { subdomain, root_dir }) {
|
||||
return puter.hosting.update(subdomain, root_dir);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'hosting_delete',
|
||||
description:
|
||||
'Unpublish a website by deleting its hosting subdomain. This takes ' +
|
||||
'https://<subdomain>.puter.site offline but does NOT delete the underlying Puter directory ' +
|
||||
'or its files. Equivalent to PuterJS puter.hosting.delete(subdomain).',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
subdomain: { type: 'string', description: 'The subdomain label of the site to unpublish.' },
|
||||
},
|
||||
required: ['subdomain'],
|
||||
},
|
||||
async handler(puter, { subdomain }) {
|
||||
return puter.hosting.delete(subdomain);
|
||||
},
|
||||
},
|
||||
|
||||
// ----- serverless workers (puter.workers) ------------------------------
|
||||
// Puter Workers are serverless JavaScript functions deployed from a file in
|
||||
// the user's Puter filesystem. The worker file defines handlers on the global
|
||||
// `router` object (router.get/router.post/...) and has the full puter.js SDK
|
||||
// available as `puter`, authenticated as the deployer (`me.puter`) or, when
|
||||
// invoked via puter.workers.exec(), the calling user (`user.puter`). Workers
|
||||
// are designed to be used WITH puter.js and Puter authentication, NOT as plain
|
||||
// standalone HTTP handlers — always read the router guide (puter_docs_get
|
||||
// "Workers/router") before writing worker code.
|
||||
{
|
||||
name: 'workers_create',
|
||||
description:
|
||||
'Deploy a serverless Puter Worker from a JavaScript file in the Puter filesystem and ' +
|
||||
'return its public URL. The worker file MUST define handlers on the global `router` object ' +
|
||||
'(router.get/router.post/router.put/router.delete) and may use the global puter.js SDK ' +
|
||||
'(`puter`) for storage, KV, AI, and more — authenticated as you, the deployer. Puter Workers ' +
|
||||
'are designed to be used WITH puter.js and Puter authentication, so BEFORE writing worker ' +
|
||||
'code load the router guide and examples via puter_docs_get with path "Workers/router". ' +
|
||||
'Typical flow: fs_write_file the worker code to a path (e.g. "/me/workers/api.js"), then ' +
|
||||
'workers_create with that file_path. TO UPDATE a deployed worker, simply write the new code ' +
|
||||
'to the SAME file with fs_write_file — there is no separate update call; the worker serves ' +
|
||||
'the current contents of its associated file (propagation takes ~5-30s). Requires a Puter ' +
|
||||
'account with a verified email. Equivalent to PuterJS puter.workers.create(worker_name, file_path).',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
worker_name: {
|
||||
type: 'string',
|
||||
description: 'Worker name (letters, digits, hyphens, underscores). Lowercased automatically.',
|
||||
},
|
||||
file_path: {
|
||||
type: 'string',
|
||||
description: 'Path to the worker JS file in Puter (e.g. "/me/workers/api.js"). The file must define handlers on the global `router` object. Max 10MB. Writing to this same path later updates the deployed worker.',
|
||||
},
|
||||
},
|
||||
required: ['worker_name', 'file_path'],
|
||||
},
|
||||
async handler(puter, { worker_name, file_path }) {
|
||||
return puter.workers.create(worker_name, file_path);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'workers_list',
|
||||
description:
|
||||
'List all serverless Workers deployed by the authenticated Puter user, including each ' +
|
||||
"worker's name, public URL, and the source file it is deployed from (write to that file to " +
|
||||
'update the worker). Equivalent to PuterJS puter.workers.list().',
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
async handler(puter) {
|
||||
return puter.workers.list();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'workers_get',
|
||||
description:
|
||||
'Get a single deployed Worker by name, including its public URL and the source file path it ' +
|
||||
'serves (write new code to that file with fs_write_file to update it). ' +
|
||||
'Equivalent to PuterJS puter.workers.get(worker_name).',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
worker_name: { type: 'string', description: 'The worker name to look up.' },
|
||||
},
|
||||
required: ['worker_name'],
|
||||
},
|
||||
async handler(puter, { worker_name }) {
|
||||
return puter.workers.get(worker_name);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'workers_exec',
|
||||
description:
|
||||
'Call a deployed Puter Worker over HTTP as the authenticated user, automatically attaching ' +
|
||||
"the Puter auth header so the worker can act on the caller's resources via `user.puter`. " +
|
||||
'Use this to invoke or test a worker endpoint. Equivalent to PuterJS puter.workers.exec(url, options).',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
url: {
|
||||
type: 'string',
|
||||
description: 'Full worker URL including any path, e.g. "https://my-worker.puter.work/api/hello" (get the base URL from workers_get/workers_list).',
|
||||
},
|
||||
method: {
|
||||
type: 'string',
|
||||
enum: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS', 'HEAD'],
|
||||
default: 'GET',
|
||||
},
|
||||
headers: {
|
||||
type: 'object',
|
||||
description: 'Optional request headers.',
|
||||
additionalProperties: { type: 'string' },
|
||||
},
|
||||
body: { type: 'string', description: 'Optional request body (for POST/PUT/PATCH).' },
|
||||
},
|
||||
required: ['url'],
|
||||
},
|
||||
async handler(puter, { url, method = 'GET', headers, body }) {
|
||||
const init = { method };
|
||||
if (headers) init.headers = headers;
|
||||
if (body != null && method !== 'GET' && method !== 'HEAD') init.body = body;
|
||||
const resp = await puter.workers.exec(url, init);
|
||||
const text = await resp.text();
|
||||
return { _meta: { status: resp.status, content_type: resp.headers.get('content-type') }, text };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'workers_delete',
|
||||
description:
|
||||
'Delete (undeploy) a Puter Worker by name, stopping its execution and releasing its URL. Does ' +
|
||||
"NOT delete the worker's source file in the filesystem. Equivalent to PuterJS puter.workers.delete(worker_name).",
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
worker_name: { type: 'string', description: 'The worker name to delete.' },
|
||||
},
|
||||
required: ['worker_name'],
|
||||
},
|
||||
async handler(puter, { worker_name }) {
|
||||
const ok = await puter.workers.delete(worker_name);
|
||||
return { success: ok === true, deleted: worker_name };
|
||||
},
|
||||
},
|
||||
|
||||
// ----- puter.js documentation ------------------------------------------
|
||||
{
|
||||
name: 'puter_docs_index',
|
||||
description:
|
||||
'Load the index of Puter / puter.js documentation (from docs.puter.com/llms.txt): a list of ' +
|
||||
'every topic and its doc path, spanning the whole puter.js SDK — Workers (serverless ' +
|
||||
'functions), Hosting, FS, KV, AI (500+ models), Auth, and more. Call this FIRST to discover ' +
|
||||
'which doc to read, then fetch the page with puter_docs_get. Puter Workers and the tools in ' +
|
||||
'this server are designed to be used WITH puter.js and Puter authentication, so consult ' +
|
||||
'these docs before writing any worker or SDK code.',
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
async handler() {
|
||||
const text = await fetchDocText(DOCS_INDEX_URL);
|
||||
return { _meta: { source: DOCS_INDEX_URL }, text };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'puter_docs_get',
|
||||
description:
|
||||
'Fetch a specific Puter / puter.js documentation page as Markdown by the topic path listed in ' +
|
||||
'puter_docs_index. Examples: "Workers/router" (the Worker router guide + canonical examples — ' +
|
||||
'read this before writing a worker), "Workers/create", "AI/chat", "KV/set", "FS/write", ' +
|
||||
'"Hosting/create". Use it to read the exact API and copy working examples before writing ' +
|
||||
'worker or puter.js code.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: {
|
||||
type: 'string',
|
||||
description: 'Doc topic path from the index, e.g. "Workers/router" or "AI/chat". A trailing "/index.md" is optional. Must be a docs.puter.com topic.',
|
||||
},
|
||||
},
|
||||
required: ['path'],
|
||||
},
|
||||
async handler(puter, { path }) {
|
||||
const url = resolveDocUrl(path);
|
||||
const text = await fetchDocText(url);
|
||||
return { _meta: { source: url }, text };
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const TOOL_MAP = new Map(TOOLS.map((t) => [t.name, t]));
|
||||
|
||||
/** Build the tools/list payload (strips internal handlers). */
|
||||
export function listTools() {
|
||||
return TOOLS.map(({ name, description, inputSchema }) => ({ name, description, inputSchema }));
|
||||
}
|
||||
|
||||
/** Pretty-print a value for a text content block. */
|
||||
export function asText(value) {
|
||||
return typeof value === 'string' ? value : JSON.stringify(value, null, 2);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// This file is not actually in the webpack project, it is handled separately.
|
||||
// Forked from src/worker/template/puter-portable.template.
|
||||
|
||||
if (globalThis.Cloudflare) {
|
||||
// Cloudflare Workers has a faulty EventTarget implementation which doesn't
|
||||
// bind "this" to the event handler.
|
||||
// https://github.com/cloudflare/workerd/issues/4453
|
||||
const CfEventTarget = EventTarget;
|
||||
globalThis.EventTarget = class EventTarget extends CfEventTarget {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
}
|
||||
|
||||
addEventListener(type, listener, options) {
|
||||
super.addEventListener(type, listener.bind(this), options);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Build a real puter.js instance bound to a specific auth token. Each call with
|
||||
// type 'userPuter' runs puter.js inside an isolated `with` context so concurrent
|
||||
// requests don't share global mutable state (auth token, caches, etc.).
|
||||
globalThis.init_puter_portable = (auth, apiOrigin, type) => {
|
||||
if (type === 'userPuter') {
|
||||
const goodContext = {};
|
||||
Object.getOwnPropertyNames(globalThis).forEach((name) => {
|
||||
try {
|
||||
goodContext[name] = globalThis[name];
|
||||
} catch {}
|
||||
});
|
||||
goodContext.globalThis = goodContext;
|
||||
goodContext.WorkerGlobalScope = WorkerGlobalScope;
|
||||
goodContext.ServiceWorkerGlobalScope = ServiceWorkerGlobalScope;
|
||||
goodContext.location = new URL('https://puter.work');
|
||||
goodContext.addEventListener = () => {};
|
||||
// @ts-ignore
|
||||
with (goodContext) {
|
||||
#include "../../puter-js/dist/puter.js"
|
||||
}
|
||||
goodContext.puter.setAPIOrigin(apiOrigin);
|
||||
goodContext.puter.setAuthToken(auth);
|
||||
return goodContext.puter;
|
||||
}
|
||||
|
||||
#include "../../puter-js/dist/puter.js"
|
||||
|
||||
puter.setAPIOrigin(apiOrigin);
|
||||
puter.setAuthToken(auth);
|
||||
};
|
||||
|
||||
#include "../dist/webpackPreamplePart.js"
|
||||
@@ -0,0 +1,62 @@
|
||||
// Forked from src/worker/webpack.config.cjs.
|
||||
// Bundles src/index.js (router + MCP routes) into dist/webpackPreamplePart.js,
|
||||
// which buildPreamble.mjs then inlines alongside puter.js into the final
|
||||
// service-worker preamble (dist/workerPreamble.js).
|
||||
const path = require('path');
|
||||
const webpack = require('webpack');
|
||||
const TerserPlugin = require('terser-webpack-plugin');
|
||||
|
||||
module.exports = {
|
||||
entry: './src/index.js',
|
||||
output: {
|
||||
path: path.resolve(__dirname, 'dist'),
|
||||
filename: 'webpackPreamplePart.js',
|
||||
library: {
|
||||
type: 'var',
|
||||
name: 'WorkerPreamble',
|
||||
},
|
||||
globalObject: 'this',
|
||||
},
|
||||
mode: 'production',
|
||||
target: 'webworker',
|
||||
resolve: {
|
||||
extensions: ['.js'],
|
||||
},
|
||||
externals: {
|
||||
'https://puter-net.b-cdn.net/rustls.js': 'undefined',
|
||||
},
|
||||
optimization: {
|
||||
minimize: true,
|
||||
minimizer: [
|
||||
new TerserPlugin({
|
||||
terserOptions: {
|
||||
keep_fnames: true,
|
||||
mangle: {
|
||||
keep_fnames: true,
|
||||
},
|
||||
compress: {
|
||||
keep_fnames: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.js$/,
|
||||
exclude: /puter\.js$/,
|
||||
parser: {
|
||||
dynamicImports: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
plugins: [
|
||||
new webpack.BannerPlugin({
|
||||
banner: '// This file is pasted before user code',
|
||||
raw: false,
|
||||
entryOnly: false,
|
||||
}),
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
name = "puter-mcp"
|
||||
# The build inlines puter.js + the bundled router into a single service-worker
|
||||
# script (built by `npm run build` before dev/deploy).
|
||||
main = "dist/workerPreamble.js"
|
||||
compatibility_date = "2025-01-01"
|
||||
|
||||
# The preamble is assembled with a C-style #include preprocessor (see
|
||||
# scripts/buildPreamble.mjs) specifically so puter.js is concatenated RAW and
|
||||
# never touched by a bundler. wrangler bundles `main` by default, and that pass
|
||||
# transpiles to strict-mode ESM which forbids the `with` statement used by the
|
||||
# init_puter_portable sandbox ("Strict mode code may not include a with
|
||||
# statement"). Uploading the script as-is keeps it a sloppy-mode service worker.
|
||||
no_bundle = true
|
||||
|
||||
# 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.)
|
||||
Reference in New Issue
Block a user