fix: mcp fs copy, move, rename (#3517)

* fix: mcp fs copy, move, rename

* feat: more mcp tools for kv
This commit is contained in:
Daniel Salazar
2026-08-07 17:14:40 -04:00
committed by GitHub
parent 085ac116c7
commit c24f0fe08b
9 changed files with 639 additions and 25 deletions
@@ -100,7 +100,9 @@ export class AppController extends PuterController {
return d;
}
registerRoutes(router) {
registerRoutes(
/** @type {import('../../core/http/PuterRouter').PuterRouter} */ router,
) {
// GET /apps — list apps owned by the current user
router.get(
'/apps',
@@ -122,8 +124,7 @@ export class AppController extends PuterController {
'/apps/nameAvailable',
{
subdomain: 'api',
requireUserActor: true,
allowFullAccessToken: true,
requireAuth: true,
},
async (req, res) => {
const name = req.query?.name;
+19 -1
View File
@@ -98,11 +98,14 @@ The Puter MCP server exposes the following tools, grouped by category. Each one
### Filesystem
- `fs_write_file`: Create or overwrite a file in your Puter filesystem.
- `fs_read_file`: Read a file's contents (UTF-8 text, or base64 for binary).
- `fs_read_file`: Read a file's contents (UTF-8 text, or base64 for binary), optionally just a byte window of it.
- `fs_readdir`: List the files and subdirectories in a directory.
- `fs_mkdir`: Create a directory, optionally creating missing parents.
- `fs_stat`: Get metadata (name, size, type, timestamps) for a file or directory.
- `fs_delete`: Delete a file or directory.
- `fs_copy`: Copy a file or directory to another location.
- `fs_move`: Move a file or directory to another location (also renames).
- `fs_rename`: Rename a file or directory in place.
### Hosting
@@ -120,6 +123,21 @@ The Puter MCP server exposes the following tools, grouped by category. Each one
- `workers_get`: Get a single worker's public URL and source file.
- `workers_delete`: Undeploy a worker.
### Key-value store
Each app has its own KV namespace inside your account. These tools use your own
user-level store by default; pass `app_uuid` to work in a specific app's store instead.
- `kv_get`: Read a key (a missing key reads as `null`).
- `kv_set`: Create or overwrite a key, optionally with an expiry timestamp.
- `kv_del`: Delete a key.
- `kv_list`: List keys, or key/value pairs, by pattern with pagination.
- `kv_incr` / `kv_decr`: Change a number, or numbers at given dot paths.
- `kv_add`: Add to the stored value — sums numbers, appends to arrays.
- `kv_update`: Set specific dot paths inside a stored object.
- `kv_remove`: Remove dot paths from a stored object.
- `kv_expire` / `kv_expire_at`: Expire a key after N seconds, or at a timestamp.
### Apps
- `apps_create`: Register a launchable Puter app pointing at a URL.
+27 -1
View File
@@ -19,12 +19,15 @@ OAuth "Sign in with Puter" flow the Worker hosts itself (see
### Filesystem
| Tool | Description |
| --- | --- |
| `fs_read_file` | Read a file (UTF-8 or base64; optional offset/length). |
| `fs_read_file` | Read a file (UTF-8 or base64; optional byte offset/length window). |
| `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. |
| `fs_copy` | Copy a file or directory to another location. |
| `fs_move` | Move a file or directory to another location (also renames). |
| `fs_rename` | Rename a file or directory in place. |
### Hosting (static websites)
Publishing a website in Puter means creating a hosting subdomain served at
@@ -53,6 +56,29 @@ its associated file (there is no separate update call).
| `workers_exec` | Call a worker over HTTP as the authenticated user. |
| `workers_delete` | Undeploy a worker (leaves its source file in place). |
### Key-value store
Puter's KV store is namespaced per app inside each user's account. This connector
authenticates with a **user token**, so these tools read and write the user's own
namespace by default; every one takes an optional `app_uuid` to target a single
app's store instead (e.g. the `sandbox-<worker>` app a deployed worker runs as).
Values are stored as JSON. Tools that take a "dot path" (`profile.bio`) address
into a stored object; the empty path is the value itself.
| Tool | Description |
| --- | --- |
| `kv_get` | Read a key (missing keys read as `null`). |
| `kv_set` | Create/overwrite a key, optionally with an expiry timestamp. |
| `kv_del` | Delete a key. |
| `kv_list` | List keys (or key/value pairs) by pattern, with pagination. |
| `kv_incr` / `kv_decr` | Change a number, or numbers at given dot paths. |
| `kv_add` | Add to the stored value (sums numbers, appends to arrays). |
| `kv_update` | Set specific dot paths inside a stored object. |
| `kv_remove` | Remove dot paths from a stored object. |
| `kv_expire` / `kv_expire_at` | Expire a key after N seconds / at a timestamp. |
`flush` is deliberately not exposed — wiping a whole store is not something an
agent should be able to do in one call.
### Apps
A Puter **app** is a registered application in your account: it shows up in your
Puter app list, can be launched in the Puter desktop UI, and (once approved) be
+16 -2
View File
@@ -12,7 +12,7 @@
"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"],
"keywords": ["puter", "filesystem", "hosting", "subdomains", "workers", "serverless", "puter.js", "fs", "kv", "key-value"],
"server": {
"type": "node",
"entry_point": "server/index.cjs",
@@ -28,12 +28,15 @@
"tools_generated": false,
"tools": [
{ "name": "whoami", "description": "Get the authenticated user's info (username, uuid, home directory). Use it to build valid paths." },
{ "name": "fs_read_file", "description": "Read a file (UTF-8 or base64; optional offset/length)." },
{ "name": "fs_read_file", "description": "Read a file (UTF-8 or base64; optional byte offset/length window)." },
{ "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": "fs_copy", "description": "Copy a file or directory to another location." },
{ "name": "fs_move", "description": "Move a file or directory to another location (also renames)." },
{ "name": "fs_rename", "description": "Rename a file or directory in place." },
{ "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)." },
@@ -44,6 +47,17 @@
{ "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": "kv_get", "description": "Read a key from the key-value store (missing keys read as null)." },
{ "name": "kv_set", "description": "Create or overwrite a key, optionally with an expiry timestamp." },
{ "name": "kv_del", "description": "Delete a key from the key-value store." },
{ "name": "kv_list", "description": "List keys (or key/value pairs) by pattern, with pagination." },
{ "name": "kv_incr", "description": "Increment a number, or numbers at given dot paths." },
{ "name": "kv_decr", "description": "Decrement a number, or numbers at given dot paths." },
{ "name": "kv_add", "description": "Add to a stored value (sums numbers, appends to arrays)." },
{ "name": "kv_update", "description": "Set specific dot paths inside a stored object." },
{ "name": "kv_remove", "description": "Remove dot paths from a stored object." },
{ "name": "kv_expire", "description": "Expire a key after a number of seconds." },
{ "name": "kv_expire_at", "description": "Expire a key at a Unix timestamp." },
{ "name": "apps_list", "description": "List the Puter apps the caller owns / can edit (name, URL, icon, aggregate usage stats)." },
{ "name": "apps_get", "description": "Get a Puter app by name; pass stats_period for detailed open/user counts over a window." },
{ "name": "apps_create", "description": "Register a new Puter app (requires name and index_url, the URL the app loads)." },
+464 -16
View File
@@ -25,8 +25,12 @@ function bytesToBase64(bytes) {
return btoa(binary);
}
/** Normalize puter.fs.read output (a Blob/Response-like) into text or base64. */
async function decodeReadResult(result, encoding) {
/**
* Normalize puter.fs.read output (a Blob/Response-like) into text or base64,
* optionally returning only the byte window [offset, offset + length).
*/
async function decodeReadResult(result, encoding, offset, length) {
const wantsWindow = offset != null || length != null;
let bytes;
if (result instanceof Blob) {
bytes = new Uint8Array(await result.arrayBuffer());
@@ -35,7 +39,7 @@ async function decodeReadResult(result, encoding) {
} else if (result instanceof Uint8Array) {
bytes = result;
} else if (typeof result === 'string') {
if (encoding === 'base64') {
if (encoding === 'base64' || wantsWindow) {
bytes = new TextEncoder().encode(result);
} else {
return { content: result, encoding: 'utf8', bytes: result.length };
@@ -48,10 +52,17 @@ async function decodeReadResult(result, encoding) {
return { content: text, encoding: 'utf8', bytes: text.length };
}
if (encoding === 'base64') {
return { content: bytesToBase64(bytes), encoding: 'base64', bytes: bytes.length };
const totalBytes = bytes.length;
if (wantsWindow) {
const start = Math.min(offset ?? 0, totalBytes);
bytes = bytes.subarray(start, length != null ? start + length : undefined);
}
return { content: new TextDecoder().decode(bytes), encoding: 'utf8', bytes: bytes.length };
const window = wantsWindow ? { offset: offset ?? 0, total_bytes: totalBytes } : {};
if (encoding === 'base64') {
return { content: bytesToBase64(bytes), encoding: 'base64', bytes: bytes.length, ...window };
}
return { content: new TextDecoder().decode(bytes), encoding: 'utf8', bytes: bytes.length, ...window };
}
// ----- puter.js documentation fetching -------------------------------------
@@ -105,9 +116,62 @@ async function fetchDocText(url) {
return resp.text();
}
/** The directory part of a Puter path ('' when the path has no directory part). */
function parentPath(path) {
const trimmed = String(path).replace(/\/+$/, '');
const cut = trimmed.lastIndexOf('/');
if (cut < 0) return '';
return cut === 0 ? '/' : trimmed.slice(0, cut);
}
/** Whether `path` exists and is a directory. */
async function isDirectory(puter, path) {
if (!path) return false;
try {
const entry = await puter.fs.stat(path, { returnSize: false });
return Boolean(entry && entry.is_dir);
} catch {
return false;
}
}
/**
* Create the directory a move will land in, for fs_move's create_missing_parents.
* The API's /move requires an existing destination parent, so the directory has
* to exist before the call.
*
* Which directory that is follows the same rule puter.fs.move applies: with an
* explicit new_name the destination IS the containing directory, and without one
* an existing directory is moved into while anything else is treated as the
* item's new full path.
*/
async function ensureMoveDestination(puter, destination, newName) {
if (newName) {
if (!await isDirectory(puter, destination)) {
await puter.fs.mkdir(destination, { createMissingParents: true });
}
return;
}
if (await isDirectory(puter, destination)) return;
const parent = parentPath(destination);
if (parent && !await isDirectory(puter, parent)) {
await puter.fs.mkdir(parent, { createMissingParents: true });
}
}
// Every Puter path lives under the user's home directory (/<username>). Agents
// routinely guess bare root paths like "/portfolio/index.html", which do NOT
// exist — this note steers them to valid forms.
// Shared by every kv_* tool. The backend pins app-scoped tokens to their own
// app and ignores the override; only a user token (what this server carries)
// can point a call at another app's store.
const KV_APP_UUID_NOTE =
'Uid of an app whose store to use instead of your own user-level store ' +
'(e.g. "app-1234abcd..." from apps_list). Omit for your own store.';
/** Wrap an app uid as the { optConfig } puter.kv methods take, or nothing. */
const kvOptConfig = (appUuid) => (appUuid ? { optConfig: { appUuid } } : {});
const HOME_PATH_NOTE =
'Paths must live under your home directory: use "~/..." or "/<username>/..." ' +
'(call whoami to get your <username>). Bare root paths like "/portfolio/index.html" are INVALID. Also, don\'t pollute the home directory. Create subpaths and folders for your projects.';
@@ -135,25 +199,28 @@ export const TOOLS = [
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).',
'pass encoding="base64" for binary files. Pass offset/length to get back only ' +
'that byte window — useful for sampling a large file instead of pulling all of ' +
'it. When a window is returned, _meta reports total_bytes so you can page through ' +
'the rest. Equivalent to PuterJS puter.fs.read(path).',
inputSchema: {
type: 'object',
properties: {
path: { type: 'string', description: `File path to read. ${HOME_PATH_NOTE}` },
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.' },
offset: { type: 'integer', minimum: 0, description: 'Byte offset to start from. Defaults to 0.' },
length: { type: 'integer', minimum: 1, description: 'Number of bytes to return. Defaults to the rest of the file.' },
},
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 };
// The window is applied here rather than passed to puter.fs.read: the SDK
// sends offset/byte_count as query params and /read only honors a Range
// header, so a pass-through would silently return the whole file.
const result = await puter.fs.read(path);
const decoded = await decodeReadResult(result, encoding, offset, length);
const { content, encoding: enc, ...meta } = decoded;
return { _meta: { encoding: enc, ...meta }, text: content };
},
},
{
@@ -259,6 +326,108 @@ export const TOOLS = [
return puter.fs.readdir(path);
},
},
{
name: 'fs_copy',
description:
'Copy a file or directory in Puter to another location. If destination is an existing ' +
'directory the item is copied into it under the same name; pass new_name to copy it under a ' +
'different name. On a name conflict the copy is auto-renamed ("file (1).txt") unless you pass ' +
'overwrite=true. Equivalent to PuterJS puter.fs.copy(source, destination).',
inputSchema: {
type: 'object',
properties: {
source: { type: 'string', description: `Path of the file or directory to copy. ${HOME_PATH_NOTE}` },
destination: {
type: 'string',
description: `Destination directory to copy into (or the full destination path when combined with new_name). ${HOME_PATH_NOTE}`,
},
new_name: { type: 'string', description: 'Name for the copy at the destination. Defaults to the source name.' },
overwrite: { type: 'boolean', default: false, description: 'Overwrite an existing item at the destination.' },
dedupe_name: {
type: 'boolean',
default: true,
description: 'Auto-rename the copy instead of failing when the name is taken.',
},
},
required: ['source', 'destination'],
},
async handler(puter, { source, destination, new_name, overwrite = false, dedupe_name = true }) {
const result = await puter.fs.copy(source, destination, {
newName: new_name,
overwrite,
dedupeName: dedupe_name,
});
// puter.fs.copy resolves to the raw API shape ([{ copied: entry }]).
// Unwrap it so every fs_* tool answers with the same item shape.
const items = (Array.isArray(result) ? result : [result])
.map((entry) => (entry && entry.copied) || entry);
return items.length === 1 ? items[0] : items;
},
},
{
name: 'fs_move',
description:
'Move a file or directory in Puter to another location. If destination is an existing ' +
'directory the item is moved into it under the same name; otherwise destination is treated as ' +
"the item's new full path (so this also renames). The destination directory must already " +
'exist unless you pass create_missing_parents. Equivalent to PuterJS ' +
'puter.fs.move(source, destination).',
inputSchema: {
type: 'object',
properties: {
source: { type: 'string', description: `Path of the file or directory to move. ${HOME_PATH_NOTE}` },
destination: {
type: 'string',
description: `Destination directory to move into, or the item's new full path. ${HOME_PATH_NOTE}`,
},
new_name: { type: 'string', description: 'Name for the item at the destination. Defaults to the source name.' },
overwrite: { type: 'boolean', default: false, description: 'Overwrite an existing item at the destination.' },
dedupe_name: {
type: 'boolean',
default: false,
description: 'Auto-rename ("file (1).txt") instead of failing when the name is taken.',
},
create_missing_parents: {
type: 'boolean',
default: false,
description: 'Create the destination directory (and any missing parents) first. It is left behind if the move itself then fails.',
},
},
required: ['source', 'destination'],
},
async handler(puter, {
source, destination, new_name, overwrite = false, dedupe_name = false, create_missing_parents = false,
}) {
if (create_missing_parents) {
await ensureMoveDestination(puter, destination, new_name);
}
const result = await puter.fs.move(source, destination, {
newName: new_name,
overwrite,
dedupeName: dedupe_name,
});
// As with fs_copy: unwrap the API's { moved: entry } envelope.
return (result && result.moved) || result;
},
},
{
name: 'fs_rename',
description:
'Rename a file or directory in Puter in place, keeping it in the same parent directory. To ' +
'move an item to a different directory use fs_move instead. Equivalent to PuterJS ' +
'puter.fs.rename(path, new_name).',
inputSchema: {
type: 'object',
properties: {
path: { type: 'string', description: `Path of the file or directory to rename. ${HOME_PATH_NOTE}` },
new_name: { type: 'string', description: 'The new name (a bare name, not a path).' },
},
required: ['path', 'new_name'],
},
async handler(puter, { path, new_name }) {
return puter.fs.rename(path, new_name);
},
},
// ----- hosting / static websites (puter.hosting) -----------------------
// In Puter, "hosting" means publishing a static website. Each website lives
@@ -682,6 +851,285 @@ export const TOOLS = [
},
},
// ----- key-value store (puter.kv) --------------------------------------
// Puter's KV store is namespaced per app within each user's account. This
// server authenticates with a user token, so by default every tool here
// reads and writes the user's own namespace; app_uuid retargets a single
// call at one app's store (for example the sandbox-<worker> app a deployed
// worker runs as, which is where that worker's own puter.kv data lives).
//
// Values are stored as JSON, so anything a JSON-RPC argument can express
// round-trips: strings, numbers, booleans, null, objects, arrays. Several
// tools address into a stored object by "dot path" — "profile.bio" means
// the `bio` field of the stored object's `profile` field — with the empty
// path "" meaning the value itself.
{
name: 'kv_get',
description:
"Read a key from the authenticated user's Puter key-value store. Returns { key, value }, " +
'where value is null when the key is not in the store — a stored null reads the same way, ' +
'so use kv_list when you need to tell those apart. Equivalent to PuterJS puter.kv.get(key).',
inputSchema: {
type: 'object',
properties: {
key: { type: 'string', description: 'Key to read (max 1 KB).' },
app_uuid: { type: 'string', description: KV_APP_UUID_NOTE },
},
required: ['key'],
},
async handler(puter, { key, app_uuid }) {
const value = await puter.kv.get({ key, ...kvOptConfig(app_uuid) });
// Wrapped in an object because a bare `undefined`/null has no text
// form to hand back as tool output.
return { key, value: value === undefined ? null : value };
},
},
{
name: 'kv_set',
description:
'Create or overwrite a key in the Puter key-value store. The value is stored as JSON ' +
'(string, number, boolean, null, object, or array; max 400 KB). Pass expire_at to have ' +
'the key removed at a given time. Equivalent to PuterJS puter.kv.set(key, value).',
inputSchema: {
type: 'object',
properties: {
key: { type: 'string', description: 'Key to write (max 1 KB).' },
value: { description: 'Value to store, as any JSON value (max 400 KB).' },
expire_at: {
type: 'integer',
description: 'Unix timestamp in seconds at which the key expires. Omit to keep it indefinitely.',
},
app_uuid: { type: 'string', description: KV_APP_UUID_NOTE },
},
required: ['key', 'value'],
},
async handler(puter, { key, value, expire_at, app_uuid }) {
return puter.kv.set({
key,
value,
...(expire_at != null ? { expireAt: expire_at } : {}),
...kvOptConfig(app_uuid),
});
},
},
{
name: 'kv_del',
description:
'Delete a key from the Puter key-value store. Succeeds whether or not the key existed. ' +
'Equivalent to PuterJS puter.kv.del(key).',
inputSchema: {
type: 'object',
properties: {
key: { type: 'string', description: 'Key to delete.' },
app_uuid: { type: 'string', description: KV_APP_UUID_NOTE },
},
required: ['key'],
},
async handler(puter, { key, app_uuid }) {
const ok = await puter.kv.del({ key, ...kvOptConfig(app_uuid) });
return { success: ok !== false, deleted: key };
},
},
{
name: 'kv_list',
description:
'List keys in the Puter key-value store, sorted by key. Returns keys only unless ' +
'return_values is true. Every page is metered and a full listing reads the whole store, ' +
'so narrow it with pattern and/or limit rather than listing everything. Equivalent to ' +
'PuterJS puter.kv.list(options).',
inputSchema: {
type: 'object',
properties: {
pattern: {
type: 'string',
description: 'Prefix filter with an optional trailing "*" ("user:" and "user:*" both match keys starting with "user:"). Defaults to all keys.',
},
return_values: {
type: 'boolean',
default: false,
description: 'Return { key, value } pairs instead of bare key names.',
},
limit: { type: 'integer', minimum: 1, description: 'Maximum number of items in this page. Returns a page object with a cursor for the rest.' },
cursor: { type: 'string', description: 'Cursor from a previous page.' },
offset: {
type: 'integer',
minimum: 0,
description: 'Skip this many items before the page (max 5000; cannot be combined with cursor). Prefer cursor — large offsets get slower and more expensive.',
},
include_total: { type: 'boolean', default: false, description: 'Include a total count of matching items in the page.' },
app_uuid: { type: 'string', description: KV_APP_UUID_NOTE },
},
},
async handler(puter, { pattern, return_values = false, limit, cursor, offset, include_total, app_uuid }) {
return puter.kv.list({
...(pattern != null ? { pattern } : {}),
returnValues: return_values,
...(limit != null ? { limit } : {}),
...(cursor != null ? { cursor } : {}),
...(offset != null ? { offset } : {}),
...(include_total != null ? { includeTotal: include_total } : {}),
...kvOptConfig(app_uuid),
});
},
},
{
name: 'kv_incr',
description:
'Increment a numeric value in the Puter key-value store and return the new value. A key ' +
'that does not exist starts at 0. Pass a number to increment the value itself, or an ' +
'object mapping dot paths to amounts to bump fields inside a stored object. Equivalent ' +
'to PuterJS puter.kv.incr(key, amount).',
inputSchema: {
type: 'object',
properties: {
key: { type: 'string', description: 'Key to increment.' },
amount: {
description: 'Number to add (default 1), or an object like { "stats.views": 2 } mapping dot paths to amounts.',
anyOf: [{ type: 'number' }, { type: 'object', additionalProperties: { type: 'number' } }],
},
app_uuid: { type: 'string', description: KV_APP_UUID_NOTE },
},
required: ['key'],
},
async handler(puter, { key, amount, app_uuid }) {
return puter.kv.incr(key, amount, kvOptConfig(app_uuid).optConfig);
},
},
{
name: 'kv_decr',
description:
'Decrement a numeric value in the Puter key-value store and return the new value. A key ' +
'that does not exist starts at 0. Pass a number to decrement the value itself, or an ' +
'object mapping dot paths to amounts. Equivalent to PuterJS puter.kv.decr(key, amount).',
inputSchema: {
type: 'object',
properties: {
key: { type: 'string', description: 'Key to decrement.' },
amount: {
description: 'Number to subtract (default 1), or an object like { "stats.views": 2 } mapping dot paths to amounts.',
anyOf: [{ type: 'number' }, { type: 'object', additionalProperties: { type: 'number' } }],
},
app_uuid: { type: 'string', description: KV_APP_UUID_NOTE },
},
required: ['key'],
},
async handler(puter, { key, amount, app_uuid }) {
return puter.kv.decr(key, amount, kvOptConfig(app_uuid).optConfig);
},
},
{
name: 'kv_add',
description:
'Add to the value already stored at a key and return the updated value — numbers are ' +
'summed and arrays are appended to. Pass a plain value to add to the value itself, or an ' +
'object mapping dot paths to the values to add at each path. To add an object as a value ' +
'rather than as a path map, use kv_update. Equivalent to PuterJS puter.kv.add(key, value).',
inputSchema: {
type: 'object',
properties: {
key: { type: 'string', description: 'Key to add to.' },
value: { description: 'Value to add (default 1). An object is read as a { "dot.path": value } map.' },
app_uuid: { type: 'string', description: KV_APP_UUID_NOTE },
},
required: ['key'],
},
async handler(puter, { key, value, app_uuid }) {
return puter.kv.add(key, value, kvOptConfig(app_uuid).optConfig);
},
},
{
name: 'kv_update',
description:
'Update fields inside the object stored at a key without overwriting the whole value, ' +
'returning the updated value. Equivalent to PuterJS puter.kv.update(key, pathAndValueMap).',
inputSchema: {
type: 'object',
properties: {
key: { type: 'string', description: 'Key to update.' },
values: {
type: 'object',
description: 'Maps dot paths to their new values, e.g. { "profile.bio": "hi", "seen": 3 }. The path "" replaces the whole value.',
additionalProperties: true,
},
ttl: { type: 'integer', minimum: 1, description: 'Optional time-to-live for the key, in seconds.' },
app_uuid: { type: 'string', description: KV_APP_UUID_NOTE },
},
required: ['key', 'values'],
},
async handler(puter, { key, values, ttl, app_uuid }) {
return puter.kv.update({
key,
pathAndValueMap: values,
...(ttl != null ? { ttl } : {}),
...kvOptConfig(app_uuid),
});
},
},
{
name: 'kv_remove',
description:
'Remove one or more fields from the object stored at a key by dot path, returning the ' +
'updated value. To delete the key itself use kv_del. Equivalent to PuterJS ' +
'puter.kv.remove(key, ...paths).',
inputSchema: {
type: 'object',
properties: {
key: { type: 'string', description: 'Key to remove fields from.' },
paths: {
type: 'array',
items: { type: 'string' },
minItems: 1,
description: 'Dot paths to remove, e.g. ["profile.bio", "seen"].',
},
app_uuid: { type: 'string', description: KV_APP_UUID_NOTE },
},
required: ['key', 'paths'],
},
async handler(puter, { key, paths, app_uuid }) {
const optConfig = kvOptConfig(app_uuid).optConfig;
// remove() takes the paths as separate arguments, with optConfig
// trailing — passing it as undefined would be read as a path.
const args = optConfig ? [...paths, optConfig] : paths;
return puter.kv.remove(key, ...args);
},
},
{
name: 'kv_expire',
description:
'Set how long a key lives, in seconds from now, after which it is removed. Equivalent to ' +
'PuterJS puter.kv.expire(key, ttlSeconds).',
inputSchema: {
type: 'object',
properties: {
key: { type: 'string', description: 'Key to expire.' },
ttl_seconds: { type: 'integer', minimum: 1, description: 'Seconds from now until the key is removed.' },
app_uuid: { type: 'string', description: KV_APP_UUID_NOTE },
},
required: ['key', 'ttl_seconds'],
},
async handler(puter, { key, ttl_seconds, app_uuid }) {
return puter.kv.expire(key, ttl_seconds, kvOptConfig(app_uuid).optConfig);
},
},
{
name: 'kv_expire_at',
description:
'Set the exact time a key is removed, as a Unix timestamp in seconds. Equivalent to ' +
'PuterJS puter.kv.expireAt(key, timestampSeconds).',
inputSchema: {
type: 'object',
properties: {
key: { type: 'string', description: 'Key to expire.' },
timestamp_seconds: { type: 'integer', description: 'Unix timestamp in seconds at which the key is removed.' },
app_uuid: { type: 'string', description: KV_APP_UUID_NOTE },
},
required: ['key', 'timestamp_seconds'],
},
async handler(puter, { key, timestamp_seconds, app_uuid }) {
return puter.kv.expireAt(key, timestamp_seconds, kvOptConfig(app_uuid).optConfig);
},
},
// ----- puter.js documentation ------------------------------------------
{
name: 'puter_docs_index',
@@ -17,6 +17,27 @@ if (globalThis.Cloudflare) {
};
}
// puter.js assumes it boots once per page, so it eagerly warms state that pays
// off over a session. This server builds an instance per request instead, and
// every one of those is pure overhead:
//
// - setAuthToken() fires POST /rao (records an app open) and GET /whoami.
// One MCP tool call is not an app open, and nothing here reads the cached
// user — puter.workers.create falls back to getUser() when it needs one.
// - The FileSystem module opens a socket whose only job is invalidating a
// cache that dies with the request. The socket object itself has to stay
// (fs.copy reads socket.id), so it's disconnected rather than removed.
const trimPerRequestOverhead = (puter) => {
puter.request_rao_ = async () => {};
puter.cacheWhoami_ = async () => null;
if (puter.fs) {
puter.fs.initializeSocket = () => {};
try {
puter.fs.socket?.disconnect();
} catch {}
}
};
// 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.).
@@ -37,6 +58,7 @@ globalThis.init_puter_portable = (auth, apiOrigin, type) => {
with (goodContext) {
#include "../../puter-js/dist/puter.js"
}
trimPerRequestOverhead(goodContext.puter);
goodContext.puter.setAPIOrigin(apiOrigin);
goodContext.puter.setAuthToken(auth);
return goodContext.puter;
@@ -14,3 +14,7 @@ export const SIGNED_BATCH_FILE_UPLOAD_CONCURRENCY = 8;
export const SIGNED_MULTIPART_PART_UPLOAD_CONCURRENCY = 8;
export const SIGNED_BATCH_WRITE_UNAVAILABLE_STATUSES = new Set([404, 405, 501]);
export const SIGNED_BATCH_SUPPORTED_ENVS = ['web', 'gui', 'app'];
// Smallest upload worth a pre-flight `/df` capacity check. Anything under this
// is cheaper to just attempt and let the server reject.
export const SPACE_CHECK_MIN_BYTES = 1024 * 1024;
@@ -6,7 +6,7 @@
import * as utils from '../../../../lib/utils.js';
import { showUsageLimitDialog } from '../../../UsageLimitDialog.js';
import getAbsolutePathForApp from '../../utils/getAbsolutePathForApp.js';
import { SIGNED_BATCH_WRITE_CAPABILITY_KEY, SIGNED_BATCH_SUPPORTED_ENVS } from './constants.js';
import { SIGNED_BATCH_WRITE_CAPABILITY_KEY, SIGNED_BATCH_SUPPORTED_ENVS, SPACE_CHECK_MIN_BYTES } from './constants.js';
import { normalizeUploadEntries, separateFilesAndDirs } from './entries.js';
import { generateThumbnails } from './thumbnails.js';
import { performSignedBatchUpload } from './signedBatchUpload.js';
@@ -125,8 +125,13 @@ const upload = async function (items, dirPath, options = {}) {
// the user uploads a very large folder/file and then the server rejects it because there is not enough space
//
// Space check in 'web' environment is currently not supported since it requires permissions.
//
// Below the threshold the check costs more than it saves: the round trip
// is a fixed cost on every write, while the upload it would have avoided
// is small, and the server still rejects an over-quota write with
// NOT_ENOUGH_SPACE (handled by `error` above).
let storage;
if ( puter.env !== 'web' ) {
if ( puter.env !== 'web' && totalSize >= SPACE_CHECK_MIN_BYTES ) {
try {
storage = await this.space();
if ( storage.capacity - storage.used < totalSize ) {
@@ -0,0 +1,76 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Both upload strategies are stubbed: these tests are about what `upload`
// decides before handing off, not about how bytes reach the server.
vi.mock('./signedBatchUpload.js', () => ({
performSignedBatchUpload: vi.fn(async () => false),
}));
vi.mock('./legacyBatchUpload.js', () => ({
performLegacyBatchUpload: vi.fn(function (ctx) {
ctx.resolve({ uid: 'uploaded' });
}),
}));
import upload from './index.js';
import { SPACE_CHECK_MIN_BYTES } from './constants.js';
const origXHR = globalThis.XMLHttpRequest;
const origPuter = globalThis.puter;
let fs;
const uploadOf = (bytes) => {
const file = new File(['x'.repeat(bytes)], 'notes.txt', { type: 'text/plain' });
return upload.call(fs, file, '/user/dir');
};
beforeEach(() => {
globalThis.XMLHttpRequest = class {
open () {}
setRequestHeader () {}
addEventListener () {}
send () {}
};
fs = {
APIOrigin: 'https://api.test',
authToken: 'test-token',
space: vi.fn(async () => ({ capacity: 1024 ** 3, used: 0 })),
};
globalThis.puter = {
authToken: 'test-token',
APIOrigin: 'https://api.test',
env: 'nodejs',
fs,
};
});
afterEach(() => {
globalThis.XMLHttpRequest = origXHR;
globalThis.puter = origPuter;
vi.clearAllMocks();
});
describe('storage capacity pre-flight', () => {
it('is skipped for an upload below the threshold', async () => {
await expect(uploadOf(64)).resolves.toEqual({ uid: 'uploaded' });
expect(fs.space).not.toHaveBeenCalled();
});
it('runs for an upload at the threshold', async () => {
await expect(uploadOf(SPACE_CHECK_MIN_BYTES)).resolves.toEqual({ uid: 'uploaded' });
expect(fs.space).toHaveBeenCalledTimes(1);
});
it('rejects an upload that would exceed the remaining space', async () => {
fs.space = vi.fn(async () => ({ capacity: SPACE_CHECK_MIN_BYTES, used: SPACE_CHECK_MIN_BYTES }));
await expect(uploadOf(SPACE_CHECK_MIN_BYTES)).rejects.toMatchObject({
code: 'NOT_ENOUGH_SPACE',
});
});
it('is skipped entirely in the web environment', async () => {
globalThis.puter.env = 'web';
await expect(uploadOf(SPACE_CHECK_MIN_BYTES)).resolves.toEqual({ uid: 'uploaded' });
expect(fs.space).not.toHaveBeenCalled();
});
});