mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-24 06:58:21 +00:00
fix: mcp fs copy, move, rename (#3517)
* fix: mcp fs copy, move, rename * feat: more mcp tools for kv
This commit is contained in:
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user