diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
new file mode 100644
index 0000000..4b2c436
--- /dev/null
+++ b/.github/workflows/ci.yaml
@@ -0,0 +1,45 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+
+jobs:
+ build:
+ name: Build & Lint
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: oven-sh/setup-bun@v1
+ with:
+ bun-version: latest
+
+ - run: bun install
+
+ - run: bun run build
+
+ - run: bun run lint
+
+ test:
+ name: E2E Tests
+ runs-on: ubuntu-latest
+ needs: build
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: oven-sh/setup-bun@v1
+ with:
+ bun-version: latest
+
+ - run: bun install
+
+ - name: Run tests
+ env:
+ UPSTASH_EMAIL: ${{ secrets.UPSTASH_EMAIL }}
+ UPSTASH_API_KEY: ${{ secrets.UPSTASH_API_KEY }}
+ UPSTASH_API_KEY_READONLY: ${{ secrets.UPSTASH_API_KEY_READONLY }}
+ UPSTASH_BOX_API_KEY: ${{ secrets.UPSTASH_BOX_API_KEY }}
+ run: bun test
diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml
index 4c76332..5aa4027 100644
--- a/.github/workflows/release.yaml
+++ b/.github/workflows/release.yaml
@@ -18,7 +18,7 @@ jobs:
uses: actions/checkout@v4
- name: Set env
- run: echo "VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
+ run: echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_ENV
- name: Setup Node
uses: actions/setup-node@v4
@@ -43,15 +43,13 @@ jobs:
run: bun run build
- name: Publish
- if: ${{ !inputs.prerelease }}
- # working-directory: ./dist
+ if: ${{ !github.event.release.prerelease }}
run: |
npm pkg delete scripts.prepare
npm publish --provenance --access public
- name: Publish release candidate
- if: ${{ inputs.prerelease }}
- # working-directory: ./dist
+ if: ${{ github.event.release.prerelease }}
run: |
npm pkg delete scripts.prepare
npm publish --provenance --access public --tag=canary
diff --git a/README.md b/README.md
index ccdc351..e8f40aa 100644
--- a/README.md
+++ b/README.md
@@ -1,38 +1,47 @@
-# Upstash MCP
+# Upstash MCP Server
-[](https://cursor.com/en/install-mcp?name=upstash&config=eyJjb21tYW5kIjoibnB4IC15IEB1cHN0YXNoL21jcC1zZXJ2ZXJAbGF0ZXN0IC0tZW1haWwgWU9VUl9FTUFJTCAtLWFwaS1rZXkgWU9VUl9BUElfS0VZIn0%3D)
+[](https://cursor.com/en/install-mcp?name=upstash&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkB1cHN0YXNoL21jcC1zZXJ2ZXJAbGF0ZXN0IiwiLS1lbWFpbCIsIllPVVJfRU1BSUwiLCItLWFwaS1rZXkiLCJZT1VSX0FQSV9LRVkiXX0%3D)
+[
](https://insiders.vscode.dev/redirect/mcp/install?name=upstash&inputs=%5B%7B%22id%22%3A%22email%22%2C%22type%22%3A%22promptString%22%2C%22description%22%3A%22Upstash%20email%22%7D%2C%7B%22id%22%3A%22apiKey%22%2C%22type%22%3A%22promptString%22%2C%22description%22%3A%22Upstash%20API%20key%22%2C%22password%22%3Atrue%7D%5D&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40upstash%2Fmcp-server%40latest%22%2C%22--email%22%2C%22%24%7Binput%3Aemail%7D%22%2C%22--api-key%22%2C%22%24%7Binput%3AapiKey%7D%22%5D%7D)
-[
](https://insiders.vscode.dev/redirect?url=vscode%3Amcp%2Finstall%3F%7B%22name%22%3A%22upstash-mcp%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40upstash%2Fmcp-server%40latest%22%2C%22--email%22%2C%22YOUR_EMAIL%22%2C%22--api-key%22%2C%22YOUR_API_KEY%22%5D%7D)
+The Upstash MCP server lets your agent manage and debug your Upstash resources directly, across **Redis**, **QStash**, **Workflow**, and **[Upstash Box](https://upstash.com/docs/box/overall/quickstart)**.
-[](https://smithery.ai/server/@upstash/mcp-server)
+> [!TIP]
+> For most workflows, prefer installing the [Upstash Skill](https://upstash.com/docs/agent-resources/skills) and letting your agent drive [`@upstash/cli`](https://upstash.com/docs/agent-resources/cli) over running the MCP server.
-The Upstash MCP gives your agent the ability to interact with your Upstash account, such as:
+## Quickstart
-### Redis
+You'll need your Upstash account email and an API key — create one at [Upstash Console → Account → API Keys](https://console.upstash.com/account/api).
-- "Create a new Redis in us-east-1"
-- "List my databases that have high memory usage"
-- "Give me the schema of how users are stored in redis"
-- "Create a backup and clear db"
-- "Give me the spikes in throughput during the last 7 days"
+The Upstash MCP server works with any MCP-compatible client. If your client isn't listed below, check its documentation for how to add a stdio MCP server, then point it at the base command:
-### QStash & Workflow
+```bash
+npx -y @upstash/mcp-server@latest --email YOUR_EMAIL --api-key YOUR_API_KEY
+```
-- "Check the logs and figure out what is wrong"
-- "Find me failed workflows of user @ysfk_0x"
-- "Restart the failed workflow run started in last 2 hours"
-- "Check DLQ and give me a summary"
+> [!NOTE]
+> Readonly API keys are supported. When the server starts with one, it automatically disables every tool that would modify state (creating databases, deleting backups, retrying workflows, etc.). Your agent can still read and query your account, but it cannot make changes.
-# Usage
+
+Claude Code
+
+Run this command in your terminal. See the [Claude Code MCP docs](https://docs.anthropic.com/en/docs/claude-code/mcp) for more info.
+
+```sh
+claude mcp add --scope user upstash -- npx -y @upstash/mcp-server@latest --email YOUR_EMAIL --api-key YOUR_API_KEY
+```
-## Quick Setup
+
-First, get your Upstash credentials:
+
+Cursor
-- **Email**: Your Upstash account email
-- **API Key**: Get it from [Upstash Console → Account → API Keys](https://console.upstash.com/account/api)
+Go to `Settings` → `Cursor Settings` → `MCP` → `Add new global MCP server`.
-Add this to your MCP client configuration:
+Pasting the following configuration into your Cursor `~/.cursor/mcp.json` file is the recommended approach. You may also install in a specific project by creating `.cursor/mcp.json` in your project folder. See the [Cursor MCP docs](https://docs.cursor.com/context/model-context-protocol) for more info.
+
+Since Cursor 1.0, you can click the install button below for instant one-click installation. Replace `YOUR_EMAIL` and `YOUR_API_KEY` with your real values before confirming.
+
+[](https://cursor.com/en/install-mcp?name=upstash&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkB1cHN0YXNoL21jcC1zZXJ2ZXJAbGF0ZXN0IiwiLS1lbWFpbCIsIllPVVJfRU1BSUwiLCItLWFwaS1rZXkiLCJZT1VSX0FQSV9LRVkiXX0%3D)
```json
{
@@ -52,39 +61,300 @@ Add this to your MCP client configuration:
}
```
-### Claude Code
+
-```bash
-claude mcp add --transport stdio upstash -- npx -y @upstash/mcp-server@latest --email YOUR_EMAIL --api-key YOUR_API_KEY
+
+Windsurf
+
+Add this to your Windsurf MCP config file at `~/.codeium/windsurf/mcp_config.json`. See the [Windsurf MCP docs](https://docs.windsurf.com/windsurf/cascade/mcp) for more info.
+
+```json
+{
+ "mcpServers": {
+ "upstash": {
+ "command": "npx",
+ "args": [
+ "-y",
+ "@upstash/mcp-server@latest",
+ "--email",
+ "YOUR_EMAIL",
+ "--api-key",
+ "YOUR_API_KEY"
+ ]
+ }
+ }
+}
```
-### Streamable HTTP Transport (for web applications)
+
-Start your MCP server with the `http` transport:
+
+OpenCode
-```bash
-npx @upstash/mcp-server@latest --transport http --port 3000 --email YOUR_EMAIL --api-key YOUR_API_KEY
+Add this to your OpenCode configuration file (`~/.config/opencode/opencode.json` or a project-level `opencode.json`). See the [OpenCode MCP docs](https://opencode.ai/docs/mcp-servers) for more info.
+
+```json
+{
+ "mcp": {
+ "upstash": {
+ "type": "local",
+ "command": [
+ "npx",
+ "-y",
+ "@upstash/mcp-server@latest",
+ "--email",
+ "YOUR_EMAIL",
+ "--api-key",
+ "YOUR_API_KEY"
+ ],
+ "enabled": true
+ }
+ }
+}
+```
+
+
+
+
+OpenAI Codex
+
+See the [OpenAI Codex MCP docs](https://developers.openai.com/codex/mcp) for more info.
+
+**Using the CLI**
+
+```sh
+codex mcp add upstash -- npx -y @upstash/mcp-server@latest --email YOUR_EMAIL --api-key YOUR_API_KEY
+```
+
+**Manual configuration**
+
+Add this to your Codex config file (`~/.codex/config.toml` or `.codex/config.toml`):
+
+```toml
+[mcp_servers.upstash]
+command = "npx"
+args = ["-y", "@upstash/mcp-server@latest", "--email", "YOUR_EMAIL", "--api-key", "YOUR_API_KEY"]
+startup_timeout_sec = 20
```
-And configure your MCP client to use the HTTP transport:
+> [!NOTE]
+> If you see startup timeout errors, increase `startup_timeout_sec` to `40`.
+
+
+
+
+VS Code
+
+Click to install — VS Code will prompt for your email and API key (stored in its secret storage):
+
+[
](https://insiders.vscode.dev/redirect/mcp/install?name=upstash&inputs=%5B%7B%22id%22%3A%22email%22%2C%22type%22%3A%22promptString%22%2C%22description%22%3A%22Upstash%20email%22%7D%2C%7B%22id%22%3A%22apiKey%22%2C%22type%22%3A%22promptString%22%2C%22description%22%3A%22Upstash%20API%20key%22%2C%22password%22%3Atrue%7D%5D&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40upstash%2Fmcp-server%40latest%22%2C%22--email%22%2C%22%24%7Binput%3Aemail%7D%22%2C%22--api-key%22%2C%22%24%7Binput%3AapiKey%7D%22%5D%7D)
+[
](https://insiders.vscode.dev/redirect/mcp/install?name=upstash&inputs=%5B%7B%22id%22%3A%22email%22%2C%22type%22%3A%22promptString%22%2C%22description%22%3A%22Upstash%20email%22%7D%2C%7B%22id%22%3A%22apiKey%22%2C%22type%22%3A%22promptString%22%2C%22description%22%3A%22Upstash%20API%20key%22%2C%22password%22%3Atrue%7D%5D&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40upstash%2Fmcp-server%40latest%22%2C%22--email%22%2C%22%24%7Binput%3Aemail%7D%22%2C%22--api-key%22%2C%22%24%7Binput%3AapiKey%7D%22%5D%7D&quality=insiders)
+
+Or add this to `.vscode/mcp.json` (or your user `mcp.servers` setting). Using `inputs` with `promptString` means your API key is prompted once and kept in VS Code's secret storage instead of sitting in the config file. See the [VS Code MCP docs](https://code.visualstudio.com/docs/copilot/chat/mcp-servers) for more info.
+
+```json
+{
+ "inputs": [
+ { "type": "promptString", "id": "email", "description": "Upstash email" },
+ { "type": "promptString", "id": "apiKey", "description": "Upstash API key", "password": true }
+ ],
+ "servers": {
+ "upstash": {
+ "type": "stdio",
+ "command": "npx",
+ "args": [
+ "-y",
+ "@upstash/mcp-server@latest",
+ "--email",
+ "${input:email}",
+ "--api-key",
+ "${input:apiKey}"
+ ]
+ }
+ }
+}
+```
+
+
+
+
+Google Antigravity
+
+Add this to your Antigravity MCP config. See the [Antigravity MCP docs](https://antigravity.google/docs/mcp) for more info.
```json
{
"mcpServers": {
"upstash": {
- "url": "http://localhost:3000/mcp"
+ "command": "npx",
+ "args": [
+ "-y",
+ "@upstash/mcp-server@latest",
+ "--email",
+ "YOUR_EMAIL",
+ "--api-key",
+ "YOUR_API_KEY"
+ ]
}
}
}
```
-## Telemetry
+
+
+
+Claude Desktop
-The server sends anonymous runtime/platform info to Upstash with each request. To opt out, add `--disable-telemetry` to your args.
+Open Claude Desktop's developer settings and edit `claude_desktop_config.json`. See the [Claude Desktop MCP docs](https://modelcontextprotocol.io/quickstart/user) for more info.
-## Troubleshooting
+```json
+{
+ "mcpServers": {
+ "upstash": {
+ "command": "npx",
+ "args": [
+ "-y",
+ "@upstash/mcp-server@latest",
+ "--email",
+ "YOUR_EMAIL",
+ "--api-key",
+ "YOUR_API_KEY"
+ ]
+ }
+ }
+}
+```
+
+
+
+
+Gemini CLI
+
+Open the Gemini CLI settings file at `~/.gemini/settings.json` and add Upstash to `mcpServers`. See [Gemini CLI Configuration](https://google-gemini.github.io/gemini-cli/docs/tools/mcp-server.html) for details.
+
+```json
+{
+ "mcpServers": {
+ "upstash": {
+ "command": "npx",
+ "args": [
+ "-y",
+ "@upstash/mcp-server@latest",
+ "--email",
+ "YOUR_EMAIL",
+ "--api-key",
+ "YOUR_API_KEY"
+ ]
+ }
+ }
+}
+```
+
+
+
+## Example prompts
+
+### Redis
+
+- _"Create a new Redis database in us-east-1"_
+- _"List my databases sorted by memory usage"_
+- _"Update the user schema by pulling from Redis"_
+- _"Create a backup of this db, then clear it"_
+- _"Show me throughput spikes during the last 7 days"_
+
+### QStash & Workflow
+
+- _"Check the QStash logs and figure out why my webhook keeps failing"_
+- _"Find failed workflow runs for user `@admin` today"_
+- _"Retry the failed workflow run that started 2 hours ago"_
+- _"Summarize what's in the DLQ right now, grouped by error type"_
+- _"Pause the schedules that are throwing errors"_
+
+### Upstash Box
+
+- _"Spin up a Box, clone this repo, and run the tests"_
+- _"Snapshot this Box and create 5 copies from it, assign each one a GitHub issue"_
+- _"My Box keeps failing to start, check the logs and tell me what's wrong"_
+
+## Upstash Box API key (optional)
+
+For the MCP to interact with [Upstash Box](https://upstash.com/docs/box/overall/quickstart), the agent needs your Box API key. By default you have to paste it into the chat (or keep it in a `.env`) every time the agent runs a Box tool. To avoid this, you can wire the key into the MCP setup itself so the server picks it up automatically on startup.
+
+You can pass it in two ways.
+
+**CLI flag**
+
+```json
+{
+ "mcpServers": {
+ "upstash": {
+ "command": "npx",
+ "args": [
+ "-y",
+ "@upstash/mcp-server@latest",
+ "--email",
+ "YOUR_EMAIL",
+ "--api-key",
+ "YOUR_API_KEY",
+ "--box-api-key",
+ "YOUR_BOX_API_KEY"
+ ]
+ }
+ }
+}
+```
+
+**Environment variable**
+
+```json
+{
+ "mcpServers": {
+ "upstash": {
+ "command": "npx",
+ "args": [
+ "-y",
+ "@upstash/mcp-server@latest",
+ "--email",
+ "YOUR_EMAIL",
+ "--api-key",
+ "YOUR_API_KEY"
+ ],
+ "env": {
+ "UPSTASH_BOX_API_KEY": "YOUR_BOX_API_KEY"
+ }
+ }
+ }
+}
+```
+
+## Debugging
+
+If the server is misbehaving or a tool keeps failing, enable verbose logging with the `--debug` flag:
+
+```json
+{
+ "mcpServers": {
+ "upstash": {
+ "command": "npx",
+ "args": [
+ "-y",
+ "@upstash/mcp-server@latest",
+ "--email",
+ "YOUR_EMAIL",
+ "--api-key",
+ "YOUR_API_KEY",
+ "--debug"
+ ]
+ }
+ }
+}
+```
+
+Every internal event is then written to **stderr**, which your MCP client surfaces in its own log viewer. Share the relevant snippet when reporting an issue on [GitHub](https://github.com/upstash/mcp/issues).
+
+## Telemetry
-See the [troubleshooting guide](https://modelcontextprotocol.io/quickstart#troubleshooting) in the official MCP documentation. You can also reach out to us at [Discord](https://discord.com/invite/w9SenAtbme) for support.
+The server sends anonymous diagnostic info to Upstash with each request: the MCP server SDK version, your runtime version (Node, Bun, etc.), and basic platform info (OS and architecture). **No account data, tool arguments, or results are collected.** To opt out, add `--disable-telemetry` to the args.
## Development
@@ -95,22 +365,24 @@ bun i
bun run watch
```
-This will continuously build the project and watch for changes.
+This continuously builds the project and watches for changes.
-For testing, you can create a `.env` file in the same directory as the project with the following content:
+For testing, create a `.env` file in the project root:
```bash
UPSTASH_EMAIL=
UPSTASH_API_KEY=
+# Optional, for Box tools:
+UPSTASH_BOX_API_KEY=
```
-To install the local MCP Server to Claude Code, run:
+To install the local MCP server into Claude Code:
```bash
claude mcp add --transport stdio upstash -- bun --watch dist/index.js --debug
```
-To view the logs from the MCP Server in real time, run:
+To tail logs from the MCP server in real time:
```bash
bun run logs
diff --git a/package.json b/package.json
index 515340c..aac86c0 100644
--- a/package.json
+++ b/package.json
@@ -5,7 +5,7 @@
"license": "MIT",
"type": "module",
"bin": {
- "mcp-server": "./dist/index.js"
+ "mcp-server": "dist/index.js"
},
"repository": {
"type": "git",
diff --git a/smithery.yaml b/smithery.yaml
index eba66f6..3e453c8 100644
--- a/smithery.yaml
+++ b/smithery.yaml
@@ -13,6 +13,11 @@ startCommand:
type: "string"
title: "API Key"
description: "Your Upstash management API key"
+ boxApiKey:
+ type: "string"
+ title: "Box API Key (optional)"
+ description: "Your Upstash Box API key. Only required if you want to use Box tools without passing the key on every tool call."
exampleConfig:
email: "user@example.com"
apiKey: "sk-example123"
+ boxApiKey: "box_example123"
diff --git a/src/config.ts b/src/config.ts
index aa96709..a3ca50b 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -1,5 +1,7 @@
export const config = {
apiKey: "",
email: "",
+ boxApiKey: "",
disableTelemetry: false,
+ readonly: false,
};
diff --git a/src/index.ts b/src/index.ts
index c1fcde6..8bfcd4a 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -29,6 +29,7 @@ const program = new Command()
.option("--port ", "port for HTTP transport", "3000")
.option("--email ", "Upstash email")
.option("--api-key ", "Upstash API key")
+ .option("--box-api-key ", "Upstash Box API key (optional)")
.option("--debug", "Enable debug mode")
.option("--disable-telemetry", "Disable telemetry headers sent to Upstash APIs")
.allowUnknownOption(); // let other wrappers pass through extra flags
@@ -40,6 +41,7 @@ const cliOptions = program.opts<{
port: string;
email?: string;
apiKey?: string;
+ boxApiKey?: string;
debug?: boolean;
disableTelemetry?: boolean;
}>();
@@ -91,6 +93,7 @@ async function main() {
// Set config
config.email = email;
config.apiKey = apiKey;
+ config.boxApiKey = cliOptions.boxApiKey || process.env.UPSTASH_BOX_API_KEY || "";
config.disableTelemetry = cliOptions.disableTelemetry ?? false;
// Test connection
diff --git a/src/readonly.test.ts b/src/readonly.test.ts
new file mode 100644
index 0000000..f1352c5
--- /dev/null
+++ b/src/readonly.test.ts
@@ -0,0 +1,174 @@
+#!/usr/bin/env bun
+
+import { describe, it, expect, beforeAll, afterAll } from "bun:test";
+import { config } from "./config";
+import { testConnection } from "./test-connection";
+import { redisDbOpsTools } from "./tools/redis/db";
+import { redisCommandTools } from "./tools/redis/command";
+import { redisBackupTools } from "./tools/redis/backup";
+import { qstashTools } from "./tools/qstash/qstash";
+import { workflowTools } from "./tools/qstash/workflow";
+import { clearTokenCache } from "./tools/qstash/utils";
+import { http } from "./http";
+import type { RedisDatabase } from "./tools/redis/types";
+import type { CustomTool } from "./tool";
+
+const redisTools = { ...redisDbOpsTools, ...redisCommandTools, ...redisBackupTools } as Record<
+ string,
+ CustomTool
+>;
+const qstashAllTools = { ...qstashTools, ...workflowTools } as Record>;
+
+// Save original config to restore after tests
+let originalEmail: string;
+let originalApiKey: string;
+let originalReadonly: boolean;
+
+beforeAll(async () => {
+ const email = process.env.UPSTASH_EMAIL;
+ const readonlyKey = process.env.UPSTASH_API_KEY_READONLY;
+
+ if (!email || !readonlyKey) {
+ throw new Error("UPSTASH_EMAIL and UPSTASH_API_KEY_READONLY must be set in .env file");
+ }
+
+ // Save original config
+ originalEmail = config.email;
+ originalApiKey = config.apiKey;
+ originalReadonly = config.readonly;
+
+ // Set readonly credentials
+ config.email = email;
+ config.apiKey = readonlyKey;
+ config.readonly = false; // Reset so testConnection can detect it
+ clearTokenCache(); // Clear any cached QStash tokens from other test files
+});
+
+afterAll(() => {
+ // Restore original config
+ config.email = originalEmail;
+ config.apiKey = originalApiKey;
+ config.readonly = originalReadonly;
+});
+
+describe("readonly detection", () => {
+ it("testConnection detects readonly API key", async () => {
+ await testConnection();
+ expect(config.readonly).toBe(true);
+ });
+});
+
+describe("server tool filtering", () => {
+ it("only registers readonly tools when config.readonly is true", () => {
+ const allTools = { ...redisTools, ...qstashAllTools };
+ const writeToolNames = Object.entries(allTools)
+ .filter(([_, tool]) => !tool.readonly)
+ .map(([name]) => name);
+
+ const readonlyToolNames = Object.entries(allTools)
+ .filter(([_, tool]) => tool.readonly)
+ .map(([name]) => name);
+
+ // Verify we have both categories defined
+ expect(writeToolNames.length).toBeGreaterThan(0);
+ expect(readonlyToolNames.length).toBeGreaterThan(0);
+
+ // Verify specific write tools are correctly NOT marked readonly
+ expect(writeToolNames).toContain("redis_database_create_new");
+ expect(writeToolNames).toContain("redis_database_delete");
+ expect(writeToolNames).toContain("redis_database_reset_password");
+ expect(writeToolNames).toContain("qstash_publish_message");
+ expect(writeToolNames).toContain("qstash_schedules_manage");
+
+ // All QStash/workflow tools should be hidden in readonly mode (not supported yet)
+ expect(writeToolNames).toContain("qstash_logs_list");
+ expect(writeToolNames).toContain("qstash_schedules_list");
+ expect(writeToolNames).toContain("workflow_logs_list");
+
+ // Verify specific Redis read tools ARE marked readonly
+ expect(readonlyToolNames).toContain("redis_database_list_databases");
+ expect(readonlyToolNames).toContain("redis_database_get_details");
+ expect(readonlyToolNames).toContain("redis_database_get_statistics");
+ expect(readonlyToolNames).toContain("redis_database_run_redis_commands");
+ });
+});
+
+describe("readonly redis read operations", () => {
+ it("can list databases", async () => {
+ const result = await redisTools.redis_database_list_databases.handler({});
+ expect(Array.isArray(result)).toBe(true);
+ });
+
+ it("can get database details", async () => {
+ const dbs = await http.get("v2/redis/databases");
+ if (dbs.length === 0) return; // Skip if no databases
+
+ const result = await redisTools.redis_database_get_details.handler({
+ database_id: dbs[0].database_id,
+ });
+
+ expect(typeof result).toBe("string");
+ expect(result).toContain(dbs[0].database_name);
+ });
+
+ it("runs read-only redis commands using read_only_rest_token", async () => {
+ const dbs = await http.get("v2/redis/databases");
+ if (dbs.length === 0) return; // Skip if no databases
+
+ const result = await redisTools.redis_database_run_redis_commands.handler({
+ database_id: dbs[0].database_id,
+ commands: [["DBSIZE"]],
+ });
+
+ const text = Array.isArray(result) ? result.join("") : String(result);
+ expect(text).toContain("result");
+ });
+});
+
+describe("readonly redis write operations blocked", () => {
+ it("rejects create database", async () => {
+ expect(
+ redisTools.redis_database_create_new.handler({
+ name: "readonly-test-should-fail",
+ primary_region: "us-east-1",
+ })
+ ).rejects.toThrow(/readonly api key/i);
+ });
+
+ it("rejects delete database", async () => {
+ expect(
+ redisTools.redis_database_delete.handler({
+ database_id: "fake-id-should-not-matter",
+ })
+ ).rejects.toThrow(/readonly api key/i);
+ });
+
+ it("rejects reset password", async () => {
+ expect(
+ redisTools.redis_database_reset_password.handler({
+ id: "fake-id-should-not-matter",
+ })
+ ).rejects.toThrow(/readonly api key/i);
+ });
+});
+
+describe("readonly qstash operations", () => {
+ it("qstash tools are not available in readonly mode", async () => {
+ expect(
+ qstashAllTools.qstash_schedules_list.handler({})
+ ).rejects.toThrow("QStash is not available in readonly mode yet");
+ });
+
+ it("workflow tools are not available in readonly mode", async () => {
+ expect(
+ qstashAllTools.workflow_logs_list.handler({ count: 3 })
+ ).rejects.toThrow("QStash is not available in readonly mode yet");
+ });
+
+ it("qstash tools are hidden from server in readonly mode", () => {
+ const allQstashTools = Object.keys(qstashAllTools);
+ for (const name of allQstashTools) {
+ expect(qstashAllTools[name].readonly).toBeFalsy();
+ }
+ });
+});
diff --git a/src/server.ts b/src/server.ts
index 3642100..54fe290 100644
--- a/src/server.ts
+++ b/src/server.ts
@@ -1,4 +1,5 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import { config } from "./config";
import { log } from "./log";
import { tools } from "./tools";
import { handlerResponseToCallResult } from "./tool";
@@ -17,7 +18,11 @@ export function createServerInstance() {
}
);
- const toolsList = Object.entries(tools).map(([name, tool]) => ({
+ const filteredTools = config.readonly
+ ? Object.fromEntries(Object.entries(tools).filter(([_, tool]) => tool.readonly))
+ : tools;
+
+ const toolsList = Object.entries(filteredTools).map(([name, tool]) => ({
name,
description: tool.description,
inputSchema: tool.inputSchema,
diff --git a/src/test-connection.ts b/src/test-connection.ts
index 6027c3c..26e29d6 100644
--- a/src/test-connection.ts
+++ b/src/test-connection.ts
@@ -1,7 +1,10 @@
+import { config } from "./config";
import { http } from "./http";
import { log } from "./log";
import type { RedisDatabase } from "./tools/redis/types";
+const READONLY_ERROR = "Readonly API key";
+
export async function testConnection() {
log("🧪 Testing connection to Upstash API");
@@ -19,5 +22,16 @@ export async function testConnection() {
if (!Array.isArray(dbs))
throw new Error("Invalid response from Upstash API. Check your API key and email.");
+ // Detect readonly API key by attempting a write operation with a fake ID
+ try {
+ await http.delete("v2/redis/database/readonly-check-nonexistent");
+ } catch (error) {
+ if (error instanceof Error && error.message.includes(READONLY_ERROR)) {
+ config.readonly = true;
+ log("🔒 Readonly API key detected. Write operations will be disabled.");
+ }
+ // "database not found" error is expected for non-readonly keys — ignore it
+ }
+
log("✅ Connection to Upstash API is successful");
}
diff --git a/src/tool.ts b/src/tool.ts
index 06ba852..7e44e43 100644
--- a/src/tool.ts
+++ b/src/tool.ts
@@ -13,6 +13,12 @@ export type CustomTool = {
*/
inputSchema?: TSchema;
+ /**
+ * Whether this tool is safe to use with a readonly API key.
+ * Tools not marked as readonly will be hidden when a readonly key is detected.
+ */
+ readonly?: boolean;
+
/**
* The handler function for the tool.
* @param input Parsed input according to the input schema.
diff --git a/src/tools/box/agent-run.ts b/src/tools/box/agent-run.ts
new file mode 100644
index 0000000..b90af11
--- /dev/null
+++ b/src/tools/box/agent-run.ts
@@ -0,0 +1,51 @@
+import { z } from "zod";
+import { tool } from "../helpers";
+import { buildBoxCommon } from "./common";
+import { getBoxClient } from "./utils";
+type RunResponse = {
+ run_id?: string;
+ output?: string;
+ metadata?: { input_tokens?: number; output_tokens?: number; cost_usd?: number };
+};
+
+export const boxAgentRunTool = {
+ box_agent_run: tool({
+ description: `Run an AI agent prompt inside an Upstash Box. The agent has access to shell, filesystem, and git inside the box. It reasons, executes commands, and iterates until the task is complete. This is a synchronous call that may take a while depending on the complexity of the prompt.`,
+ get inputSchema() {
+ return z.object({
+ box_id: z.string().describe("The box ID to run the agent in"),
+ prompt: z.string().describe("The natural-language prompt for the agent to execute"),
+ model: z.string().optional().describe("Override the box's default LLM model for this run"),
+ folder: z.string().optional().describe("Working directory inside the box for the agent"),
+ ...buildBoxCommon(),
+ });
+ },
+ handler: async (params) => {
+ const { box_id, prompt, model, folder } = params;
+ const client = getBoxClient(params);
+
+ const body: Record = { prompt };
+ if (model) body.model = model;
+ if (folder) body.folder = folder;
+
+ const response = await client.post(`v2/box/${box_id}/run`, body);
+
+ const result: string[] = [`Agent run completed`];
+
+ if (response.run_id) {
+ result.push(`Run ID: ${response.run_id}`);
+ }
+
+ result.push(response.output || "(no output)");
+
+ if (response.metadata) {
+ result.push(
+ `Tokens: ${response.metadata.input_tokens ?? 0} in / ${response.metadata.output_tokens ?? 0} out` +
+ (response.metadata.cost_usd ? ` ($${response.metadata.cost_usd.toFixed(4)})` : "")
+ );
+ }
+
+ return result;
+ },
+ }),
+};
diff --git a/src/tools/box/box.test.ts b/src/tools/box/box.test.ts
new file mode 100644
index 0000000..a1a8cbd
--- /dev/null
+++ b/src/tools/box/box.test.ts
@@ -0,0 +1,324 @@
+#!/usr/bin/env bun
+
+import { describe, it, expect, beforeAll, afterAll } from "bun:test";
+import type { CustomTool } from "../../tool";
+import { boxManageTool } from "./manage";
+import { boxExecTool } from "./exec";
+import { boxAgentRunTool } from "./agent-run";
+import { boxLogsTool } from "./logs";
+import { boxRunsTool } from "./runs";
+import { boxPreviewTool } from "./preview";
+import { boxSnapshotsTool } from "./snapshots";
+
+const tools = {
+ ...boxManageTool,
+ ...boxExecTool,
+ ...boxAgentRunTool,
+ ...boxLogsTool,
+ ...boxRunsTool,
+ ...boxPreviewTool,
+ ...boxSnapshotsTool,
+} as Record>;
+
+const E2E_PREFIX = "mcp-e2e-";
+let boxApiKey: string;
+let createdBoxId: string;
+let createdSnapshotId: string;
+let agentRunId: string;
+
+beforeAll(() => {
+ const key = process.env.UPSTASH_BOX_API_KEY;
+ if (!key) {
+ throw new Error("UPSTASH_BOX_API_KEY must be set in .env file");
+ }
+ boxApiKey = key;
+});
+
+afterAll(async () => {
+ // Cleanup: delete any boxes with the e2e prefix that might be lingering
+ try {
+ const result = await tools.box_manage.handler({
+ action: "list",
+ box_api_key: boxApiKey,
+ });
+ const listText = Array.isArray(result) ? result.join("") : String(result);
+ const parsed = JSON.parse(
+ listText.replace(/^Found \d+ boxes/, "").trim() || "[]"
+ );
+ if (Array.isArray(parsed)) {
+ for (const box of parsed) {
+ if (box.name?.startsWith(E2E_PREFIX)) {
+ try {
+ await tools.box_manage.handler({
+ action: "delete",
+ box_id: box.id,
+ box_api_key: boxApiKey,
+ });
+ // eslint-disable-next-line no-console
+ console.log(`Cleanup: deleted box ${box.id} (${box.name})`);
+ } catch {
+ // ignore cleanup errors
+ }
+ }
+ }
+ }
+ } catch {
+ // ignore cleanup errors
+ }
+
+ // Cleanup snapshots
+ if (createdSnapshotId && createdBoxId) {
+ try {
+ await tools.box_snapshots.handler({
+ action: "delete",
+ box_id: createdBoxId,
+ snapshot_id: createdSnapshotId,
+ box_api_key: boxApiKey,
+ });
+ // eslint-disable-next-line no-console
+ console.log(`Cleanup: deleted snapshot ${createdSnapshotId}`);
+ } catch {
+ // ignore
+ }
+ }
+});
+
+describe("box_manage", () => {
+ it("creates a box", async () => {
+ const result = await tools.box_manage.handler({
+ action: "create",
+ name: `${E2E_PREFIX}${Date.now()}`,
+ model: "claude/sonnet_4_6",
+ runtime: "node",
+ ephemeral: true,
+ ttl: 600,
+ box_api_key: boxApiKey,
+ });
+ const text = Array.isArray(result) ? result.join("\n") : String(result);
+ expect(text).toContain("Box created successfully");
+ expect(text).toContain("Box ID:");
+
+ // Extract box ID
+ const idMatch = text.match(/Box ID: ([\w-]+)/);
+ expect(idMatch).not.toBeNull();
+ createdBoxId = idMatch![1];
+ }, 30_000);
+
+ it("lists boxes", async () => {
+ const result = await tools.box_manage.handler({
+ action: "list",
+ box_api_key: boxApiKey,
+ });
+ const text = Array.isArray(result) ? result.join("\n") : String(result);
+ expect(text).toMatch(/Found \d+ boxes/);
+ });
+
+ it("gets a box by id", async () => {
+ expect(createdBoxId).toBeDefined();
+ const result = await tools.box_manage.handler({
+ action: "get",
+ box_id: createdBoxId,
+ box_api_key: boxApiKey,
+ });
+ const text = Array.isArray(result) ? result.join("\n") : String(result);
+ expect(text).toContain(createdBoxId);
+ });
+
+ // Note: pause/resume/fork are not tested here because the test uses ephemeral boxes
+ // which don't support these actions. They work on non-ephemeral boxes.
+});
+
+describe("box_exec", () => {
+ it("executes a shell command", async () => {
+ expect(createdBoxId).toBeDefined();
+ const result = await tools.box_exec.handler({
+ box_id: createdBoxId,
+ command: ["echo", "hello from mcp e2e"],
+ box_api_key: boxApiKey,
+ });
+ const text = Array.isArray(result) ? result.join("\n") : String(result);
+ expect(text).toContain("hello from mcp e2e");
+ }, 30_000);
+});
+
+describe("box_agent_run", () => {
+ it("runs an agent prompt", async () => {
+ expect(createdBoxId).toBeDefined();
+ const result = await tools.box_agent_run.handler({
+ box_id: createdBoxId,
+ prompt: "Echo 'agent-test-ok' to stdout using a shell command, nothing else.",
+ box_api_key: boxApiKey,
+ });
+ const text = Array.isArray(result) ? result.join("\n") : String(result);
+ expect(text).toContain("Agent run completed");
+
+ // Extract run ID for later tests
+ const runIdMatch = text.match(/Run ID: ([\w-]+)/);
+ if (runIdMatch) {
+ agentRunId = runIdMatch[1];
+ }
+ }, 120_000);
+});
+
+describe("box_logs", () => {
+ it("gets box logs", async () => {
+ expect(createdBoxId).toBeDefined();
+ const result = await tools.box_logs.handler({
+ box_id: createdBoxId,
+ limit: 10,
+ box_api_key: boxApiKey,
+ });
+ const text = Array.isArray(result) ? result.join("\n") : String(result);
+ // Might have logs or might be empty for a fresh box
+ expect(text).toMatch(/Found \d+ log entries|No logs found/);
+ });
+});
+
+describe("box_runs", () => {
+ it("lists runs", async () => {
+ expect(createdBoxId).toBeDefined();
+ const result = await tools.box_runs.handler({
+ action: "list",
+ box_id: createdBoxId,
+ box_api_key: boxApiKey,
+ });
+ const text = Array.isArray(result) ? result.join("\n") : String(result);
+ expect(text).toMatch(/Found \d+ runs/);
+ });
+
+ it("gets a run by id", async () => {
+ expect(createdBoxId).toBeDefined();
+ // If we have a run ID from the agent test, use it; otherwise list runs first
+ let runId = agentRunId;
+ if (!runId) {
+ const listResult = await tools.box_runs.handler({
+ action: "list",
+ box_id: createdBoxId,
+ box_api_key: boxApiKey,
+ });
+ const listText = Array.isArray(listResult) ? listResult.join("") : String(listResult);
+ const runsJson = listText.replace(/^Found \d+ runs/, "").trim();
+ const runs = JSON.parse(runsJson || "[]");
+ if (runs.length === 0) {
+ // eslint-disable-next-line no-console
+ console.log("No runs available to test get — skipping");
+ return;
+ }
+ runId = runs[0].id;
+ }
+
+ const result = await tools.box_runs.handler({
+ action: "get",
+ box_id: createdBoxId,
+ run_id: runId,
+ box_api_key: boxApiKey,
+ });
+ const text = Array.isArray(result) ? result.join("\n") : String(result);
+ expect(text).toContain(runId);
+ });
+});
+
+describe("box_preview", () => {
+ it("lists previews (initially empty)", async () => {
+ expect(createdBoxId).toBeDefined();
+ const result = await tools.box_preview.handler({
+ action: "list",
+ box_id: createdBoxId,
+ box_api_key: boxApiKey,
+ });
+ const text = Array.isArray(result) ? result.join("\n") : String(result);
+ expect(text).toMatch(/Found \d+ preview URLs/);
+ });
+
+ it("creates a preview URL", async () => {
+ expect(createdBoxId).toBeDefined();
+ const result = await tools.box_preview.handler({
+ action: "create",
+ box_id: createdBoxId,
+ port: 3000,
+ box_api_key: boxApiKey,
+ });
+ const text = Array.isArray(result) ? result.join("\n") : String(result);
+ expect(text).toContain("Preview URL created:");
+ expect(text).toContain("Port: 3000");
+ });
+
+ it("deletes the preview URL", async () => {
+ expect(createdBoxId).toBeDefined();
+ const result = await tools.box_preview.handler({
+ action: "delete",
+ box_id: createdBoxId,
+ port: 3000,
+ box_api_key: boxApiKey,
+ });
+ const text = Array.isArray(result) ? result.join("\n") : String(result);
+ expect(text).toContain("deleted successfully");
+ });
+});
+
+describe("box_snapshots", () => {
+ it("creates a snapshot", async () => {
+ expect(createdBoxId).toBeDefined();
+ const result = await tools.box_snapshots.handler({
+ action: "create",
+ box_id: createdBoxId,
+ name: `${E2E_PREFIX}snapshot-${Date.now()}`,
+ box_api_key: boxApiKey,
+ });
+ const text = Array.isArray(result) ? result.join("\n") : String(result);
+ expect(text).toContain("Snapshot created");
+ expect(text).toContain("Snapshot ID:");
+
+ const idMatch = text.match(/Snapshot ID: ([\w-]+)/);
+ expect(idMatch).not.toBeNull();
+ createdSnapshotId = idMatch![1];
+ }, 30_000);
+
+ it("lists snapshots for the box", async () => {
+ expect(createdBoxId).toBeDefined();
+ const result = await tools.box_snapshots.handler({
+ action: "list",
+ box_id: createdBoxId,
+ box_api_key: boxApiKey,
+ });
+ const text = Array.isArray(result) ? result.join("\n") : String(result);
+ expect(text).toMatch(/Found \d+ snapshots/);
+ });
+
+ it("lists all snapshots", async () => {
+ const result = await tools.box_snapshots.handler({
+ action: "list_all",
+ box_api_key: boxApiKey,
+ });
+ const text = Array.isArray(result) ? result.join("\n") : String(result);
+ expect(text).toMatch(/Found \d+ snapshots total/);
+ });
+
+ it("deletes the snapshot", async () => {
+ expect(createdBoxId).toBeDefined();
+ expect(createdSnapshotId).toBeDefined();
+ const result = await tools.box_snapshots.handler({
+ action: "delete",
+ box_id: createdBoxId,
+ snapshot_id: createdSnapshotId,
+ box_api_key: boxApiKey,
+ });
+ const text = Array.isArray(result) ? result.join("\n") : String(result);
+ expect(text).toContain("deleted successfully");
+ // Mark as cleaned up so afterAll doesn't try again
+ createdSnapshotId = "";
+ });
+});
+
+describe("box_manage cleanup", () => {
+ it("deletes the box", async () => {
+ expect(createdBoxId).toBeDefined();
+ const result = await tools.box_manage.handler({
+ action: "delete",
+ box_id: createdBoxId,
+ box_api_key: boxApiKey,
+ });
+ const text = Array.isArray(result) ? result.join("\n") : String(result);
+ expect(text).toContain("deleted successfully");
+ });
+});
diff --git a/src/tools/box/common.ts b/src/tools/box/common.ts
new file mode 100644
index 0000000..a845128
--- /dev/null
+++ b/src/tools/box/common.ts
@@ -0,0 +1,24 @@
+import { z } from "zod";
+import { config } from "../../config";
+
+const BOX_BASE_URL = "https://us-east-1.box.upstash.com";
+
+export function buildBoxCommon() {
+ const hasConfigKey = Boolean(config.boxApiKey);
+ return {
+ box_api_key: hasConfigKey
+ ? z
+ .string()
+ .optional()
+ .describe(
+ "NOTE: The api key is already pre-configured at server startup; only pass this to override the configured key."
+ )
+ : z
+ .string()
+ .describe(
+ "Box API key (starts with 'box_'). Check the project's .env file for BOX_API_KEY, or ask the user for it."
+ ),
+ };
+}
+
+export { BOX_BASE_URL };
diff --git a/src/tools/box/exec.ts b/src/tools/box/exec.ts
new file mode 100644
index 0000000..92c524a
--- /dev/null
+++ b/src/tools/box/exec.ts
@@ -0,0 +1,48 @@
+import { z } from "zod";
+import { tool } from "../helpers";
+import { buildBoxCommon } from "./common";
+import { getBoxClient } from "./utils";
+type ExecResponse = { exit_code: number; output?: string; error?: string };
+
+export const boxExecTool = {
+ box_exec: tool({
+ description: `Execute a shell command inside an Upstash Box container. Use this for file operations, git commands, package installs, or any shell operation inside the box.`,
+ get inputSchema() {
+ return z.object({
+ box_id: z.string().describe("The box ID to execute the command in"),
+ command: z
+ .array(z.string())
+ .describe("Command and arguments as an array (e.g. ['bash', '-c', 'ls -la'])"),
+ folder: z.string().optional().describe("Working directory inside the box"),
+ async: z
+ .boolean()
+ .optional()
+ .describe("If true, return immediately without waiting for completion"),
+ ...buildBoxCommon(),
+ });
+ },
+ handler: async (params) => {
+ const { box_id, command, folder, async: isAsync } = params;
+ const client = getBoxClient(params);
+
+ const body: Record = { command };
+ if (folder) body.folder = folder;
+ if (isAsync) body.async = isAsync;
+
+ const response = await client.post(`v2/box/${box_id}/exec`, body);
+
+ if (response.exit_code !== 0) {
+ return [
+ `Command failed with exit code ${response.exit_code}`,
+ response.output ? `stdout: ${response.output}` : "",
+ response.error ? `stderr: ${response.error}` : "",
+ ].filter(Boolean);
+ }
+
+ return [
+ `Command executed successfully (exit code: ${response.exit_code})`,
+ response.output || "(no output)",
+ ];
+ },
+ }),
+};
diff --git a/src/tools/box/index.ts b/src/tools/box/index.ts
new file mode 100644
index 0000000..e08225f
--- /dev/null
+++ b/src/tools/box/index.ts
@@ -0,0 +1,18 @@
+import type { CustomTool } from "../../tool";
+import { boxManageTool } from "./manage";
+import { boxExecTool } from "./exec";
+import { boxAgentRunTool } from "./agent-run";
+import { boxLogsTool } from "./logs";
+import { boxRunsTool } from "./runs";
+import { boxPreviewTool } from "./preview";
+import { boxSnapshotsTool } from "./snapshots";
+
+export const boxTools: Record = {
+ ...boxManageTool,
+ ...boxExecTool,
+ ...boxAgentRunTool,
+ ...boxLogsTool,
+ ...boxRunsTool,
+ ...boxPreviewTool,
+ ...boxSnapshotsTool,
+};
diff --git a/src/tools/box/logs.ts b/src/tools/box/logs.ts
new file mode 100644
index 0000000..78e80b3
--- /dev/null
+++ b/src/tools/box/logs.ts
@@ -0,0 +1,43 @@
+import { z } from "zod";
+import { json, tool } from "../helpers";
+import { buildBoxCommon } from "./common";
+import { getBoxClient } from "./utils";
+
+export const boxLogsTool = {
+ box_logs: tool({
+ description: `Get logs from an Upstash Box container. Useful for debugging what happened inside the box. Returns timestamped log entries from the system, user, and agent sources.`,
+ get inputSchema() {
+ return z.object({
+ box_id: z.string().describe("The box ID to get logs for"),
+ offset: z
+ .number()
+ .optional()
+ .default(0)
+ .describe("Starting position for log entries (default: 0)"),
+ limit: z
+ .number()
+ .max(1000)
+ .optional()
+ .default(100)
+ .describe("Maximum number of log entries to return (max 1000, default: 100)"),
+ ...buildBoxCommon(),
+ });
+ },
+ handler: async (params) => {
+ const { box_id, offset, limit } = params;
+ const client = getBoxClient(params);
+
+ const response = await client.get<{ logs: unknown[] }>(`v2/box/${box_id}/logs`, {
+ offset,
+ limit,
+ });
+
+ const logs = response.logs ?? [];
+ if (logs.length === 0) {
+ return "No logs found for this box";
+ }
+
+ return [`Found ${logs.length} log entries`, json(logs)];
+ },
+ }),
+};
diff --git a/src/tools/box/manage.ts b/src/tools/box/manage.ts
new file mode 100644
index 0000000..b5223fa
--- /dev/null
+++ b/src/tools/box/manage.ts
@@ -0,0 +1,133 @@
+import { z } from "zod";
+import { json, tool } from "../helpers";
+import { buildBoxCommon } from "./common";
+import { getBoxClient } from "./utils";
+type BoxRef = { id: string; status: string };
+
+export const boxManageTool = {
+ box_manage: tool({
+ description: `Manage Upstash Box containers. Supports creating, listing, getting, deleting, pausing, resuming, and forking boxes. Boxes are secure cloud containers with built-in AI agent capabilities.`,
+ get inputSchema() {
+ return z.object({
+ action: z
+ .enum(["create", "list", "get", "delete", "pause", "resume", "fork"])
+ .describe("The action to perform"),
+ box_id: z
+ .string()
+ .optional()
+ .describe("Box ID (required for get, delete, pause, resume, fork)"),
+ // Create-specific fields
+ name: z.string().optional().describe("Display name for the box"),
+ model: z
+ .string()
+ .optional()
+ .describe(
+ "LLM model to use (e.g. 'claude/sonnet_4_6', 'openai/o4-mini'). Required for create"
+ ),
+ agent: z
+ .enum(["claude-code", "codex", "opencode"])
+ .optional()
+ .default("claude-code")
+ .describe("Agent type (default: claude-code)"),
+ runtime: z
+ .string()
+ .optional()
+ .default("node")
+ .describe("Runtime environment (e.g. 'node', 'python')"),
+ agent_api_key: z
+ .string()
+ .optional()
+ .describe("API key for the AI agent provider. Empty uses managed key"),
+ env_vars: z
+ .record(z.string())
+ .optional()
+ .describe("Environment variables to set in the box"),
+ clone_repo: z.string().optional().describe("Git repository URL to clone into the box"),
+ clone_token: z.string().optional().describe("Token for cloning private repositories"),
+ ephemeral: z.boolean().optional().describe("If true, box auto-deletes after TTL expires"),
+ ttl: z
+ .number()
+ .optional()
+ .describe("Time-to-live in seconds for ephemeral boxes (max 259200 = 3 days)"),
+ // List-specific fields
+ status: z
+ .enum(["active", "deleted"])
+ .optional()
+ .describe("Filter for list action: 'active' (default) or 'deleted'"),
+ ...buildBoxCommon(),
+ });
+ },
+ handler: async (params) => {
+ const { action, box_id } = params;
+ const client = getBoxClient(params);
+
+ switch (action) {
+ case "create": {
+ if (!params.model) {
+ throw new Error("model is required for create action");
+ }
+ const body: Record = {
+ model: params.model,
+ };
+ if (params.name) body.name = params.name;
+ if (params.agent) body.agent = params.agent;
+ if (params.runtime) body.runtime = params.runtime;
+ if (params.agent_api_key) body.agent_api_key = params.agent_api_key;
+ if (params.env_vars) body.env_vars = params.env_vars;
+ if (params.clone_repo) body.clone_repo = params.clone_repo;
+ if (params.clone_token) body.clone_token = params.clone_token;
+ if (params.ephemeral !== undefined) body.ephemeral = params.ephemeral;
+ if (params.ttl !== undefined) body.ttl = params.ttl;
+
+ const box = await client.post("v2/box", body);
+ return [
+ `Box created successfully (status: ${box.status})`,
+ `Box ID: ${box.id}`,
+ json(box),
+ ];
+ }
+
+ case "list": {
+ const query: Record = {};
+ if (params.status === "deleted") query.status = "deleted";
+ const boxes = await client.get("v2/box", query);
+ return [`Found ${boxes.length} boxes`, json(boxes)];
+ }
+
+ case "get": {
+ if (!box_id) throw new Error("box_id is required for get action");
+ const box = await client.get(`v2/box/${box_id}`);
+ return [`Box ${box_id} (status: ${box.status})`, json(box)];
+ }
+
+ case "delete": {
+ if (!box_id) throw new Error("box_id is required for delete action");
+ await client.delete(`v2/box/${box_id}`);
+ return `Box ${box_id} deleted successfully`;
+ }
+
+ case "pause": {
+ if (!box_id) throw new Error("box_id is required for pause action");
+ await client.post(`v2/box/${box_id}/pause`);
+ return `Box ${box_id} paused successfully`;
+ }
+
+ case "resume": {
+ if (!box_id) throw new Error("box_id is required for resume action");
+ await client.post(`v2/box/${box_id}/resume`);
+ return `Box ${box_id} resumed successfully`;
+ }
+
+ case "fork": {
+ if (!box_id) throw new Error("box_id is required for fork action");
+ const forked = await client.post(`v2/box/${box_id}/fork`);
+ return [`Box forked successfully`, `New Box ID: ${forked.id}`, json(forked)];
+ }
+
+ default: {
+ throw new Error(`Unknown action: ${action}`);
+ }
+ }
+ },
+ }),
+};
diff --git a/src/tools/box/preview.ts b/src/tools/box/preview.ts
new file mode 100644
index 0000000..2800d8e
--- /dev/null
+++ b/src/tools/box/preview.ts
@@ -0,0 +1,81 @@
+import { z } from "zod";
+import { json, tool } from "../helpers";
+import { buildBoxCommon } from "./common";
+import { getBoxClient } from "./utils";
+type CreatePreviewResponse = {
+ url: string;
+ port: number;
+ username?: string;
+ password?: string;
+ token?: string;
+};
+
+export const boxPreviewTool = {
+ box_preview: tool({
+ description: `Manage preview URLs for web applications running inside an Upstash Box. Create public URLs to access services running on specific ports, list existing previews, or delete them.`,
+ get inputSchema() {
+ return z.object({
+ action: z.enum(["create", "list", "delete"]).describe("The action to perform"),
+ box_id: z.string().describe("The box ID"),
+ port: z
+ .number()
+ .min(1)
+ .max(65_535)
+ .optional()
+ .describe("Port number (required for create and delete)"),
+ basic_auth: z
+ .boolean()
+ .optional()
+ .describe("Enable basic auth on the preview URL (create only)"),
+ bearer_token: z
+ .boolean()
+ .optional()
+ .describe("Enable bearer token auth on the preview URL (create only)"),
+ ...buildBoxCommon(),
+ });
+ },
+ handler: async (params) => {
+ const { action, box_id, port, basic_auth, bearer_token } = params;
+ const client = getBoxClient(params);
+
+ switch (action) {
+ case "create": {
+ if (!port) throw new Error("port is required for create action");
+ const body: Record = { port };
+ if (basic_auth) body.basic_auth = basic_auth;
+ if (bearer_token) body.bearer_token = bearer_token;
+
+ const response = await client.post(
+ `v2/box/${box_id}/preview`,
+ body
+ );
+
+ const result: string[] = [
+ `Preview URL created: ${response.url}`,
+ `Port: ${response.port}`,
+ ];
+ if (response.username) result.push(`Username: ${response.username}`);
+ if (response.password) result.push(`Password: ${response.password}`);
+ if (response.token) result.push(`Token: ${response.token}`);
+ return result;
+ }
+
+ case "list": {
+ const response = await client.get<{ previews: unknown[] }>(`v2/box/${box_id}/preview`);
+ const previews = response.previews ?? [];
+ return [`Found ${previews.length} preview URLs`, json(previews)];
+ }
+
+ case "delete": {
+ if (!port) throw new Error("port is required for delete action");
+ await client.delete(`v2/box/${box_id}/preview/${port}`);
+ return `Preview for port ${port} deleted successfully`;
+ }
+
+ default: {
+ throw new Error(`Unknown action: ${action}`);
+ }
+ }
+ },
+ }),
+};
diff --git a/src/tools/box/runs.ts b/src/tools/box/runs.ts
new file mode 100644
index 0000000..d16d77e
--- /dev/null
+++ b/src/tools/box/runs.ts
@@ -0,0 +1,47 @@
+import { z } from "zod";
+import { json, tool } from "../helpers";
+import { buildBoxCommon } from "./common";
+import { getBoxClient } from "./utils";
+type RunRef = { status: string };
+
+export const boxRunsTool = {
+ box_runs: tool({
+ description: `List, get details, or cancel runs (execution history) for an Upstash Box. Useful for debugging past agent runs and shell executions, checking their status, output, token usage, and costs.`,
+ get inputSchema() {
+ return z.object({
+ action: z.enum(["list", "get", "cancel"]).describe("The action to perform"),
+ box_id: z.string().describe("The box ID"),
+ run_id: z.string().optional().describe("Run ID (required for get and cancel actions)"),
+ ...buildBoxCommon(),
+ });
+ },
+ handler: async (params) => {
+ const { action, box_id, run_id } = params;
+ const client = getBoxClient(params);
+
+ switch (action) {
+ case "list": {
+ const response = await client.get<{ runs: unknown[] }>(`v2/box/${box_id}/runs`);
+ const runs = response.runs ?? [];
+ return [`Found ${runs.length} runs`, json(runs)];
+ }
+
+ case "get": {
+ if (!run_id) throw new Error("run_id is required for get action");
+ const run = await client.get(`v2/box/${box_id}/runs/${run_id}`);
+ return [`Run ${run_id} (status: ${run.status})`, json(run)];
+ }
+
+ case "cancel": {
+ if (!run_id) throw new Error("run_id is required for cancel action");
+ await client.post(`v2/box/${box_id}/runs/${run_id}/cancel`);
+ return `Run ${run_id} cancelled successfully`;
+ }
+
+ default: {
+ throw new Error(`Unknown action: ${action}`);
+ }
+ }
+ },
+ }),
+};
diff --git a/src/tools/box/snapshots.ts b/src/tools/box/snapshots.ts
new file mode 100644
index 0000000..f006b27
--- /dev/null
+++ b/src/tools/box/snapshots.ts
@@ -0,0 +1,113 @@
+import { z } from "zod";
+import { json, tool } from "../helpers";
+import { buildBoxCommon } from "./common";
+import { getBoxClient } from "./utils";
+type BoxRef = { id: string; status: string };
+type SnapshotRef = { id: string; status: string };
+
+export const boxSnapshotsTool = {
+ box_snapshots: tool({
+ description: `Manage Upstash Box snapshots. Create full filesystem snapshots of a box, list snapshots, delete them, or restore a box from a snapshot.`,
+ get inputSchema() {
+ return z.object({
+ action: z
+ .enum(["create", "list", "list_all", "delete", "restore"])
+ .describe(
+ "The action to perform. 'list' lists snapshots for a specific box, 'list_all' lists all your snapshots"
+ ),
+ box_id: z.string().optional().describe("Box ID (required for create, list, delete)"),
+ snapshot_id: z
+ .string()
+ .optional()
+ .describe("Snapshot ID (required for delete and restore)"),
+ // Create-specific
+ name: z.string().optional().describe("Name for the snapshot (auto-generated if empty)"),
+ // Restore-specific
+ model: z
+ .string()
+ .optional()
+ .describe("LLM model for the restored box (required for restore)"),
+ runtime: z
+ .string()
+ .optional()
+ .describe("Override the snapshot's runtime for the restored box"),
+ env_vars: z
+ .record(z.string())
+ .optional()
+ .describe("Environment variables for the restored box"),
+ ephemeral: z.boolean().optional().describe("Create the restored box as ephemeral"),
+ ttl: z
+ .number()
+ .optional()
+ .describe("TTL in seconds for the restored ephemeral box (max 259200)"),
+ ...buildBoxCommon(),
+ });
+ },
+ handler: async (params) => {
+ const { action, box_id, snapshot_id } = params;
+ const client = getBoxClient(params);
+
+ switch (action) {
+ case "create": {
+ if (!box_id) throw new Error("box_id is required for create action");
+ const body: Record = {};
+ if (params.name) body.name = params.name;
+
+ const snapshot = await client.post(`v2/box/${box_id}/snapshots`, body);
+ return [
+ `Snapshot created (status: ${snapshot.status})`,
+ `Snapshot ID: ${snapshot.id}`,
+ json(snapshot),
+ ];
+ }
+
+ case "list": {
+ if (!box_id) throw new Error("box_id is required for list action");
+ const response = await client.get<{ snapshots: SnapshotRef[] }>(
+ `v2/box/${box_id}/snapshots`
+ );
+ const snapshots = response.snapshots ?? [];
+ return [`Found ${snapshots.length} snapshots for box ${box_id}`, json(snapshots)];
+ }
+
+ case "list_all": {
+ const response = await client.get<{ snapshots: SnapshotRef[] }>("v2/box/snapshots");
+ const snapshots = response.snapshots ?? [];
+ return [`Found ${snapshots.length} snapshots total`, json(snapshots)];
+ }
+
+ case "delete": {
+ if (!box_id) throw new Error("box_id is required for delete action");
+ if (!snapshot_id) throw new Error("snapshot_id is required for delete action");
+ await client.delete(`v2/box/${box_id}/snapshots/${snapshot_id}`);
+ return `Snapshot ${snapshot_id} deleted successfully`;
+ }
+
+ case "restore": {
+ if (!snapshot_id) throw new Error("snapshot_id is required for restore action");
+ if (!params.model) throw new Error("model is required for restore action");
+
+ const body: Record = {
+ snapshot_id: snapshot_id,
+ model: params.model,
+ };
+ if (params.runtime) body.runtime = params.runtime;
+ if (params.env_vars) body.env_vars = params.env_vars;
+ if (params.ephemeral !== undefined) body.ephemeral = params.ephemeral;
+ if (params.ttl !== undefined) body.ttl = params.ttl;
+
+ const box = await client.post("v2/box/from-snapshot", body);
+ return [
+ `Box restored from snapshot (status: ${box.status})`,
+ `New Box ID: ${box.id}`,
+ json(box),
+ ];
+ }
+
+ default: {
+ throw new Error(`Unknown action: ${action}`);
+ }
+ }
+ },
+ }),
+};
diff --git a/src/tools/box/utils.ts b/src/tools/box/utils.ts
new file mode 100644
index 0000000..1e80893
--- /dev/null
+++ b/src/tools/box/utils.ts
@@ -0,0 +1,16 @@
+import { config } from "../../config";
+import { HttpClient } from "../../http";
+import { BOX_BASE_URL } from "./common";
+
+export function getBoxClient(params: { box_api_key?: string }): HttpClient {
+ const apiKey = params.box_api_key || config.boxApiKey;
+ if (!apiKey) {
+ throw new Error(
+ "No Box API key available. Pass box_api_key as a tool argument, or configure the server with --box-api-key / UPSTASH_BOX_API_KEY env var."
+ );
+ }
+ return new HttpClient({
+ baseUrl: BOX_BASE_URL,
+ qstashToken: apiKey,
+ });
+}
diff --git a/src/tools/index.ts b/src/tools/index.ts
index 20eea59..45e2ea6 100644
--- a/src/tools/index.ts
+++ b/src/tools/index.ts
@@ -1,10 +1,12 @@
import type { CustomTool } from "../tool";
import { redisTools } from "./redis";
import { qstashAllTools } from "./qstash";
+import { boxTools } from "./box";
export { json, tool } from "./helpers";
export const tools: Record = {
...redisTools,
...qstashAllTools,
+ ...boxTools,
} as unknown as Record;
diff --git a/src/tools/qstash/utils.ts b/src/tools/qstash/utils.ts
index c8be978..70e5635 100644
--- a/src/tools/qstash/utils.ts
+++ b/src/tools/qstash/utils.ts
@@ -1,3 +1,4 @@
+import { config } from "../../config";
import { http, createQStashClient, type HttpClient } from "../../http";
import type { QStashUser } from "./types";
@@ -45,6 +46,12 @@ export async function createQStashClientWithToken(options: {
region: string;
local_mode_port: number;
}): Promise {
+ if (config.readonly) {
+ throw new Error(
+ "QStash is not available in readonly mode yet. This feature will be implemented in the near future."
+ );
+ }
+
const { qstash_creds, region, local_mode_port } = options;
if (qstash_creds) {
diff --git a/src/tools/redis/backup.ts b/src/tools/redis/backup.ts
index 0a377ee..22e4892 100644
--- a/src/tools/redis/backup.ts
+++ b/src/tools/redis/backup.ts
@@ -58,6 +58,7 @@ export const redisBackupTools = {
}),
redis_database_list_backups: tool({
+ readonly: true,
// TODO: Add explanation for fields
// TODO: Is this in bytes?
description: `List all backups of a specific Upstash redis database.`,
diff --git a/src/tools/redis/command.ts b/src/tools/redis/command.ts
index df65b95..4ce6abe 100644
--- a/src/tools/redis/command.ts
+++ b/src/tools/redis/command.ts
@@ -2,6 +2,7 @@ import { z } from "zod";
import { json, tool } from "../helpers";
import { log } from "../../log";
import { http } from "../../http";
+import { config } from "../../config";
import type { RedisDatabase } from "./types";
type RedisCommandResult =
@@ -14,6 +15,7 @@ type RedisCommandResult =
export const redisCommandTools = {
redis_database_run_redis_commands: tool({
+ readonly: true,
description: `Run one or more Redis commands on a specific Upstash redis database. Either provide database_id OR both database_rest_url and database_rest_token.
NOTE: For discovery, use SCAN over KEYS. Use TYPE to get the type of a key.
NOTE: SCAN cursor [MATCH pattern] [COUNT count] [TYPE type]
@@ -51,7 +53,7 @@ NOTE: Multiple commands will be executed as a pipeline for better performance.`,
log("Fetching database details for database_id:", database_id);
const db = await http.get(["v2/redis/database", database_id]);
restUrl = "https://" + db.endpoint;
- restToken = db.rest_token;
+ restToken = config.readonly ? db.read_only_rest_token : db.rest_token;
}
if (!restUrl || !restToken) {
diff --git a/src/tools/redis/db.ts b/src/tools/redis/db.ts
index 0fea422..ff198f5 100644
--- a/src/tools/redis/db.ts
+++ b/src/tools/redis/db.ts
@@ -58,6 +58,7 @@ NOTE: Ask user for the region and name of the database.${GENERIC_DATABASE_NOTES}
}),
redis_database_list_databases: tool({
+ readonly: true,
description: `List all Upstash redis databases. Only their names and ids.${GENERIC_DATABASE_NOTES}`,
handler: async () => {
const dbs = await http.get("v2/redis/databases");
@@ -88,6 +89,7 @@ NOTE: Ask user for the region and name of the database.${GENERIC_DATABASE_NOTES}
}),
redis_database_get_details: tool({
+ readonly: true,
description: `Get further details of a specific Upstash redis database. Includes all details of the database including usage statistics.
db_disk_threshold: Total disk usage limit.
db_memory_threshold: Maximum memory usage.
@@ -138,6 +140,7 @@ ${GENERIC_DATABASE_NOTES}
}),
redis_database_get_statistics: tool({
+ readonly: true,
description: `Get comprehensive usage statistics of an Upstash redis database. Returns both:
1. PRECISE 5-day usage: Exact command count and bandwidth usage over the last 5 days
2. SAMPLED period stats: Sampled statistics over a specified period (1h, 3h, 12h, 1d, 3d, 7d) for performance monitoring
diff --git a/src/tools/utils.ts b/src/tools/utils.ts
index 1c0311d..14dec68 100644
--- a/src/tools/utils.ts
+++ b/src/tools/utils.ts
@@ -3,6 +3,7 @@ import { tool } from "./helpers";
export const utilTools = {
util_timestamps_to_date: tool({
+ readonly: true,
description: `Use this tool to convert a timestamp to a human-readable date`,
inputSchema: z.object({
timestamps: z.array(z.number()).describe("Array of timestamps to convert"),
@@ -12,6 +13,7 @@ export const utilTools = {
},
}),
util_dates_to_timestamps: tool({
+ readonly: true,
description: `Use this tool to convert an array of ISO 8601 dates to an array of timestamps`,
inputSchema: z.object({
dates: z.array(z.string()).describe("Array of dates to convert"),