mirror of
https://github.com/HeyPuter/puter.git
synced 2026-07-08 16:21:40 +00:00
beginnings of mcp
This commit is contained in:
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,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":{}}}'
|
||||
```
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,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"
|
||||
@@ -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,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"
|
||||
Reference in New Issue
Block a user