beginnings of mcp

This commit is contained in:
ProgrammerIn-wonderland
2026-06-02 20:51:21 -04:00
parent 7b7604440f
commit f109ca4591
14 changed files with 2764 additions and 0 deletions
+1481
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
node_modules/
.wrangler/
dist/
.dev.vars
*.log
+181
View File
@@ -0,0 +1,181 @@
# 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 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 <token>` header, and all
operations run as that user.
## 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. |
### Subdomains
| Tool | Description |
| --- | --- |
| `subdomains_list` | List the caller's subdomains. |
| `subdomains_get` | Get a subdomain by name. |
| `subdomains_create` | Create a subdomain (optionally pointing at a `root_dir`). |
| `subdomains_update` | Update a subdomain's `root_dir`. |
| `subdomains_delete` | Delete a subdomain. |
### 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 routes. |
| [`src/mcp.js`](src/mcp.js) | MCP JSON-RPC dispatch (initialize / tools.list / tools.call). |
| [`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` (uncomment the
`[vars]` block in `wrangler.toml`). The router reads `globalThis.puter_endpoint`
and defaults to `https://api.puter.com`.
## Getting a Puter token
In a browser logged into Puter, open the devtools console and run
`puter.authToken`. Treat it like a password.
## Connecting a client
### Option A — 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 B — 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 subdomains
curl -s $URL -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"subdomains_list","arguments":{}}}'
```
+63
View File
@@ -0,0 +1,63 @@
{
"manifest_version": "0.3",
"name": "puter-mcp-connector",
"display_name": "Puter",
"version": "0.1.0",
"description": "Use your own Puter account's filesystem and subdomains 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, and manage subdomains. 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", "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": "subdomains_list", "description": "List the caller's subdomains." },
{ "name": "subdomains_get", "description": "Get a subdomain by name." },
{ "name": "subdomains_create", "description": "Create a subdomain (optionally pointing at a root_dir)." },
{ "name": "subdomains_update", "description": "Update a subdomain's root directory." },
{ "name": "subdomains_delete", "description": "Delete a subdomain." }
],
"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"
}
}
}
+132
View File
@@ -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();
});
+22
View File
@@ -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})`);
+6
View File
@@ -0,0 +1,6 @@
import initS2w from './s2w-router.js';
import registerMcpRoutes from './mcp.js';
// Bring up the (forked) Puter worker router, then register the MCP routes on it.
initS2w();
registerMcpRoutes(globalThis.router);
+191
View File
@@ -0,0 +1,191 @@
// 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 and subdomain tools.',
});
}
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;
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 and subdomain 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);
}
+218
View File
@@ -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;
+278
View File
@@ -0,0 +1,278 @@
// 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 };
}
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.',
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.',
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.',
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).',
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.',
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.',
inputSchema: {
type: 'object',
properties: {
path: { type: 'string', description: 'Directory path to list.' },
},
required: ['path'],
},
async handler(puter, { path }) {
return puter.fs.readdir(path);
},
},
// ----- subdomains (puter.hosting) --------------------------------------
{
name: 'subdomains_list',
description: 'List all subdomains owned by the authenticated Puter user.',
inputSchema: { type: 'object', properties: {} },
async handler(puter) {
return puter.hosting.list();
},
},
{
name: 'subdomains_get',
description: 'Get a single subdomain (and its root directory) by name.',
inputSchema: {
type: 'object',
properties: {
subdomain: { type: 'string', description: 'The subdomain label, e.g. "my-site".' },
},
required: ['subdomain'],
},
async handler(puter, { subdomain }) {
return puter.hosting.get(subdomain);
},
},
{
name: 'subdomains_create',
description:
'Create a new subdomain. Optionally point it at a Puter directory (root_dir) to host a static site.',
inputSchema: {
type: 'object',
properties: {
subdomain: {
type: 'string',
description: 'Subdomain label (lowercase letters, digits, hyphens; max 64 chars).',
},
root_dir: {
type: 'string',
description: 'Optional Puter directory path the subdomain serves from.',
},
},
required: ['subdomain'],
},
async handler(puter, { subdomain, root_dir }) {
return root_dir
? puter.hosting.create(subdomain, root_dir)
: puter.hosting.create(subdomain);
},
},
{
name: 'subdomains_update',
description: "Update an existing subdomain's root directory.",
inputSchema: {
type: 'object',
properties: {
subdomain: { type: 'string', description: 'The subdomain label to update.' },
root_dir: {
type: 'string',
description: 'New Puter directory path to serve from.',
},
},
required: ['subdomain', 'root_dir'],
},
async handler(puter, { subdomain, root_dir }) {
return puter.hosting.update(subdomain, root_dir);
},
},
{
name: 'subdomains_delete',
description: 'Delete a subdomain by name.',
inputSchema: {
type: 'object',
properties: {
subdomain: { type: 'string', description: 'The subdomain label to delete.' },
},
required: ['subdomain'],
},
async handler(puter, { subdomain }) {
return puter.hosting.delete(subdomain);
},
},
];
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"
+62
View File
@@ -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,
}),
],
};
+18
View File
@@ -0,0 +1,18 @@
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: 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.
# [vars]
# puter_endpoint = "https://api.puter.com"