diff --git a/src/backend/controllers/apps/AppController.js b/src/backend/controllers/apps/AppController.js index 43719dd38..8d8e6280f 100644 --- a/src/backend/controllers/apps/AppController.js +++ b/src/backend/controllers/apps/AppController.js @@ -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; diff --git a/src/docs/src/mcp.md b/src/docs/src/mcp.md index 4a73308d2..c99e097ba 100644 --- a/src/docs/src/mcp.md +++ b/src/docs/src/mcp.md @@ -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. diff --git a/src/mcp-connector/README.md b/src/mcp-connector/README.md index 849a47363..0c1b450b0 100644 --- a/src/mcp-connector/README.md +++ b/src/mcp-connector/README.md @@ -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-` 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 diff --git a/src/mcp-connector/mcpb/manifest.json b/src/mcp-connector/mcpb/manifest.json index 7fa82ff41..01dce4fa6 100644 --- a/src/mcp-connector/mcpb/manifest.json +++ b/src/mcp-connector/mcpb/manifest.json @@ -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 .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)." }, diff --git a/src/mcp-connector/src/tools.js b/src/mcp-connector/src/tools.js index b8f6fd5d1..08b2ded0d 100644 --- a/src/mcp-connector/src/tools.js +++ b/src/mcp-connector/src/tools.js @@ -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 (/). 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 "//..." ' + '(call whoami to get your ). 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- 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', diff --git a/src/mcp-connector/template/puter-portable.template b/src/mcp-connector/template/puter-portable.template index b9a1b8270..1061fde75 100644 --- a/src/mcp-connector/template/puter-portable.template +++ b/src/mcp-connector/template/puter-portable.template @@ -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; diff --git a/src/puter-js/src/modules/FileSystem/operations/upload/constants.js b/src/puter-js/src/modules/FileSystem/operations/upload/constants.js index 7ffe17b0b..45bfa93a9 100644 --- a/src/puter-js/src/modules/FileSystem/operations/upload/constants.js +++ b/src/puter-js/src/modules/FileSystem/operations/upload/constants.js @@ -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; diff --git a/src/puter-js/src/modules/FileSystem/operations/upload/index.js b/src/puter-js/src/modules/FileSystem/operations/upload/index.js index 546a101ae..631624525 100644 --- a/src/puter-js/src/modules/FileSystem/operations/upload/index.js +++ b/src/puter-js/src/modules/FileSystem/operations/upload/index.js @@ -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 ) { diff --git a/src/puter-js/src/modules/FileSystem/operations/upload/index.test.js b/src/puter-js/src/modules/FileSystem/operations/upload/index.test.js new file mode 100644 index 000000000..e74a645b5 --- /dev/null +++ b/src/puter-js/src/modules/FileSystem/operations/upload/index.test.js @@ -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(); + }); +});