From aba1e031336a53fead94e76d4324b3d540df0b18 Mon Sep 17 00:00:00 2001 From: ytkimirti Date: Fri, 3 Apr 2026 10:45:11 +0300 Subject: [PATCH 1/8] feat: add upstash box --- .github/workflows/ci.yaml | 44 +++++ src/tools/box/agent-run.ts | 53 ++++++ src/tools/box/box.test.ts | 321 +++++++++++++++++++++++++++++++++++++ src/tools/box/common.ts | 11 ++ src/tools/box/exec.ts | 49 ++++++ src/tools/box/index.ts | 18 +++ src/tools/box/logs.ts | 45 ++++++ src/tools/box/manage.ts | 147 +++++++++++++++++ src/tools/box/preview.ts | 79 +++++++++ src/tools/box/runs.ts | 55 +++++++ src/tools/box/snapshots.ts | 124 ++++++++++++++ src/tools/box/types.ts | 125 +++++++++++++++ src/tools/box/utils.ts | 9 ++ src/tools/index.ts | 2 + 14 files changed, 1082 insertions(+) create mode 100644 .github/workflows/ci.yaml create mode 100644 src/tools/box/agent-run.ts create mode 100644 src/tools/box/box.test.ts create mode 100644 src/tools/box/common.ts create mode 100644 src/tools/box/exec.ts create mode 100644 src/tools/box/index.ts create mode 100644 src/tools/box/logs.ts create mode 100644 src/tools/box/manage.ts create mode 100644 src/tools/box/preview.ts create mode 100644 src/tools/box/runs.ts create mode 100644 src/tools/box/snapshots.ts create mode 100644 src/tools/box/types.ts create mode 100644 src/tools/box/utils.ts diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..cc4d87c --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,44 @@ +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_BOX_API_KEY: ${{ secrets.UPSTASH_BOX_API_KEY }} + run: bun test diff --git a/src/tools/box/agent-run.ts b/src/tools/box/agent-run.ts new file mode 100644 index 0000000..8df4543 --- /dev/null +++ b/src/tools/box/agent-run.ts @@ -0,0 +1,53 @@ +import { z } from "zod"; +import { json, tool } from "../helpers"; +import { boxCommon } from "./common"; +import { getBoxClient } from "./utils"; +import type { RunResponse } from "./types"; + +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.`, + inputSchema: 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"), + ...boxCommon, + }), + 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..12f67d7 --- /dev/null +++ b/src/tools/box/box.test.ts @@ -0,0 +1,321 @@ +#!/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, + }); + 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, + }); + 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) { + 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..36e7fee --- /dev/null +++ b/src/tools/box/common.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; + +const BOX_BASE_URL = "https://us-east-1.box.upstash.com"; + +export const boxCommon = { + box_api_key: z + .string() + .describe("Box API key (starts with 'box_' or 'abx_')"), +}; + +export { BOX_BASE_URL }; diff --git a/src/tools/box/exec.ts b/src/tools/box/exec.ts new file mode 100644 index 0000000..4d97674 --- /dev/null +++ b/src/tools/box/exec.ts @@ -0,0 +1,49 @@ +import { z } from "zod"; +import { json, tool } from "../helpers"; +import { boxCommon } from "./common"; +import { getBoxClient } from "./utils"; +import type { ExecResponse } from "./types"; + +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.`, + inputSchema: 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"), + ...boxCommon, + }), + 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..aa6b0e3 --- /dev/null +++ b/src/tools/box/logs.ts @@ -0,0 +1,45 @@ +import { z } from "zod"; +import { json, tool } from "../helpers"; +import { boxCommon } from "./common"; +import { getBoxClient } from "./utils"; +import type { BoxLogEntry } from "./types"; + +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.`, + inputSchema: 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)"), + ...boxCommon, + }), + handler: async (params) => { + const { box_id, offset, limit } = params; + const client = getBoxClient(params); + + const response = await client.get<{ logs: BoxLogEntry[] }>( + `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..8865683 --- /dev/null +++ b/src/tools/box/manage.ts @@ -0,0 +1,147 @@ +import { z } from "zod"; +import { json, tool } from "../helpers"; +import { boxCommon } from "./common"; +import { getBoxClient } from "./utils"; +import type { Box } from "./types"; + +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.`, + inputSchema: 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'"), + ...boxCommon, + }), + 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..874c11d --- /dev/null +++ b/src/tools/box/preview.ts @@ -0,0 +1,79 @@ +import { z } from "zod"; +import { json, tool } from "../helpers"; +import { boxCommon } from "./common"; +import { getBoxClient } from "./utils"; +import type { BoxPreview, CreatePreviewResponse } from "./types"; + +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.`, + inputSchema: 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(65535) + .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)"), + ...boxCommon, + }), + 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: BoxPreview[] }>( + `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..9a6cbf6 --- /dev/null +++ b/src/tools/box/runs.ts @@ -0,0 +1,55 @@ +import { z } from "zod"; +import { json, tool } from "../helpers"; +import { boxCommon } from "./common"; +import { getBoxClient } from "./utils"; +import type { BoxRun } from "./types"; + +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.`, + inputSchema: 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)"), + ...boxCommon, + }), + handler: async (params) => { + const { action, box_id, run_id } = params; + const client = getBoxClient(params); + + switch (action) { + case "list": { + const response = await client.get<{ runs: BoxRun[] }>(`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..5cb3961 --- /dev/null +++ b/src/tools/box/snapshots.ts @@ -0,0 +1,124 @@ +import { z } from "zod"; +import { json, tool } from "../helpers"; +import { boxCommon } from "./common"; +import { getBoxClient } from "./utils"; +import type { Box, BoxSnapshot } from "./types"; + +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.`, + inputSchema: 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)"), + ...boxCommon, + }), + 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: BoxSnapshot[] }>( + `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: BoxSnapshot[] }>("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/types.ts b/src/tools/box/types.ts new file mode 100644 index 0000000..b82ebfd --- /dev/null +++ b/src/tools/box/types.ts @@ -0,0 +1,125 @@ +export interface Box { + id: string; + customer_id: string; + name?: string; + model: string; + agent?: string; + runtime?: string; + status: string; + session_id?: string; + clone_repo?: string; + ephemeral?: boolean; + expires_at?: number; + total_input_tokens: number; + total_output_tokens: number; + total_prompts: number; + total_cpu_ns: number; + total_compute_cost_usd: number; + total_token_cost_usd: number; + use_managed_key: boolean; + mcp_servers?: McpServer[]; + enabled_skills?: string[]; + env_vars?: Record; + network_policy?: NetworkPolicy; + git_user_name?: string; + git_user_email?: string; + last_activity_at?: number; + created_at: number; + updated_at: number; +} + +export interface McpServer { + name: string; + source: string; + package_or_url: string; + args?: string[]; + headers?: Record; + enabled?: boolean; +} + +export interface NetworkPolicy { + mode: string; + allowed_domains?: string[]; + allowed_cidrs?: string[]; + denied_cidrs?: string[]; +} + +export interface ExecResponse { + exit_code: number; + output: string; + error?: string; + cpu_ns?: number; +} + +export interface RunResponse { + run_id?: string; + output: string; + metadata?: RunMetadata; +} + +export interface RunMetadata { + input_tokens?: number; + output_tokens?: number; + cached_input_tokens?: number; + cost_usd?: number; +} + +export interface BoxLogEntry { + timestamp: number; + level: string; + source: string; + message: string; +} + +export interface BoxRun { + id: string; + box_id: string; + type: string; + status: string; + prompt?: string; + model?: string; + output?: string; + input_tokens: number; + output_tokens: number; + cached_input_tokens?: number; + cost_usd: number; + duration_ms: number; + cpu_ns?: number; + compute_cost_usd?: number; + memory_peak_bytes?: number; + error_message?: string; + session_id?: string; + schedule_id?: string; + created_at: number; + completed_at?: number; +} + +export interface BoxPreview { + id: string; + box_id: string; + port: number; + username?: string; + password?: string; + token?: string; + created_at: number; +} + +export interface CreatePreviewResponse { + url: string; + port: number; + username?: string; + password?: string; + token?: string; +} + +export interface BoxSnapshot { + id: string; + box_id: string; + name: string; + runtime?: string; + model?: string; + ephemeral?: boolean; + size_bytes: number; + status: string; + created_at: number; +} diff --git a/src/tools/box/utils.ts b/src/tools/box/utils.ts new file mode 100644 index 0000000..51aaf67 --- /dev/null +++ b/src/tools/box/utils.ts @@ -0,0 +1,9 @@ +import { HttpClient } from "../../http"; +import { BOX_BASE_URL } from "./common"; + +export function getBoxClient(params: { box_api_key: string }): HttpClient { + return new HttpClient({ + baseUrl: BOX_BASE_URL, + qstashToken: params.box_api_key, + }); +} 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; From c8efd49b9ddd861bafb13517cb3a69d3bd2404c9 Mon Sep 17 00:00:00 2001 From: ytkimirti Date: Mon, 6 Apr 2026 09:27:44 +0300 Subject: [PATCH 2/8] feat: support readonly API keys Detect readonly API keys during initialization and hide write tools. When a readonly key is detected, only tools marked as readonly are registered. Redis commands use read_only_rest_token in readonly mode. QStash tools are hidden since readonly tokens don't expose QStash credentials yet. --- .github/workflows/ci.yaml | 1 + src/config.ts | 1 + src/readonly.test.ts | 174 +++++++++++++++++++++++++++++++++++++ src/server.ts | 7 +- src/test-connection.ts | 14 +++ src/tool.ts | 6 ++ src/tools/box/manage.ts | 3 +- src/tools/box/preview.ts | 5 +- src/tools/box/runs.ts | 3 +- src/tools/box/snapshots.ts | 3 +- src/tools/qstash/utils.ts | 7 ++ src/tools/redis/backup.ts | 1 + src/tools/redis/command.ts | 4 +- src/tools/redis/db.ts | 3 + src/tools/utils.ts | 2 + 15 files changed, 227 insertions(+), 7 deletions(-) create mode 100644 src/readonly.test.ts diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index cc4d87c..4b2c436 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -40,5 +40,6 @@ jobs: 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/src/config.ts b/src/config.ts index aa96709..6c4496d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,4 +2,5 @@ export const config = { apiKey: "", email: "", disableTelemetry: false, + readonly: false, }; 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/manage.ts b/src/tools/box/manage.ts index 8865683..009b181 100644 --- a/src/tools/box/manage.ts +++ b/src/tools/box/manage.ts @@ -139,8 +139,9 @@ export const boxManageTool = { ]; } - default: + default: { throw new Error(`Unknown action: ${action}`); + } } }, }), diff --git a/src/tools/box/preview.ts b/src/tools/box/preview.ts index 874c11d..526fe6d 100644 --- a/src/tools/box/preview.ts +++ b/src/tools/box/preview.ts @@ -15,7 +15,7 @@ export const boxPreviewTool = { port: z .number() .min(1) - .max(65535) + .max(65_535) .optional() .describe("Port number (required for create and delete)"), basic_auth: z @@ -71,8 +71,9 @@ export const boxPreviewTool = { return `Preview for port ${port} deleted successfully`; } - default: + default: { throw new Error(`Unknown action: ${action}`); + } } }, }), diff --git a/src/tools/box/runs.ts b/src/tools/box/runs.ts index 9a6cbf6..ffeea07 100644 --- a/src/tools/box/runs.ts +++ b/src/tools/box/runs.ts @@ -47,8 +47,9 @@ export const boxRunsTool = { return `Run ${run_id} cancelled successfully`; } - default: + default: { throw new Error(`Unknown action: ${action}`); + } } }, }), diff --git a/src/tools/box/snapshots.ts b/src/tools/box/snapshots.ts index 5cb3961..d4104a5 100644 --- a/src/tools/box/snapshots.ts +++ b/src/tools/box/snapshots.ts @@ -116,8 +116,9 @@ export const boxSnapshotsTool = { ]; } - default: + default: { throw new Error(`Unknown action: ${action}`); + } } }, }), 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"), From b0595f20cae9fee556f96bf49765949631a2428d Mon Sep 17 00:00:00 2001 From: ytkimirti Date: Mon, 6 Apr 2026 09:33:21 +0300 Subject: [PATCH 3/8] fix: resolve pre-existing lint errors in box tools --- src/tools/box/agent-run.ts | 2 +- src/tools/box/box.test.ts | 3 +++ src/tools/box/exec.ts | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/tools/box/agent-run.ts b/src/tools/box/agent-run.ts index 8df4543..c0ad96f 100644 --- a/src/tools/box/agent-run.ts +++ b/src/tools/box/agent-run.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { json, tool } from "../helpers"; +import { tool } from "../helpers"; import { boxCommon } from "./common"; import { getBoxClient } from "./utils"; import type { RunResponse } from "./types"; diff --git a/src/tools/box/box.test.ts b/src/tools/box/box.test.ts index 12f67d7..a1a8cbd 100644 --- a/src/tools/box/box.test.ts +++ b/src/tools/box/box.test.ts @@ -54,6 +54,7 @@ afterAll(async () => { 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 @@ -74,6 +75,7 @@ afterAll(async () => { snapshot_id: createdSnapshotId, box_api_key: boxApiKey, }); + // eslint-disable-next-line no-console console.log(`Cleanup: deleted snapshot ${createdSnapshotId}`); } catch { // ignore @@ -198,6 +200,7 @@ describe("box_runs", () => { 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; } diff --git a/src/tools/box/exec.ts b/src/tools/box/exec.ts index 4d97674..748145a 100644 --- a/src/tools/box/exec.ts +++ b/src/tools/box/exec.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { json, tool } from "../helpers"; +import { tool } from "../helpers"; import { boxCommon } from "./common"; import { getBoxClient } from "./utils"; import type { ExecResponse } from "./types"; From 707ff6c7a1d687f86998e43239d1cb9bc0bc2c37 Mon Sep 17 00:00:00 2001 From: ytkimirti Date: Wed, 15 Apr 2026 13:11:23 +0300 Subject: [PATCH 4/8] feat(box): support Box API key via CLI flag and env var Add --box-api-key CLI flag and UPSTASH_BOX_API_KEY env var so users don't have to pass the Box key on every tool call. When configured at startup, box_api_key becomes optional in each Box tool's input schema (with an override-only description); otherwise it stays required with a description telling the agent to check .env or ask the user. --- src/config.ts | 1 + src/index.ts | 3 + src/tools/box/agent-run.ts | 28 ++++----- src/tools/box/common.ts | 23 +++++-- src/tools/box/exec.ts | 31 +++++----- src/tools/box/logs.ts | 47 +++++++------- src/tools/box/manage.ts | 123 ++++++++++++++++--------------------- src/tools/box/preview.ts | 53 ++++++++-------- src/tools/box/runs.ts | 31 ++++------ src/tools/box/snapshots.ts | 91 ++++++++++++--------------- src/tools/box/utils.ts | 11 +++- 11 files changed, 208 insertions(+), 234 deletions(-) diff --git a/src/config.ts b/src/config.ts index 6c4496d..a3ca50b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +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/tools/box/agent-run.ts b/src/tools/box/agent-run.ts index c0ad96f..44186fc 100644 --- a/src/tools/box/agent-run.ts +++ b/src/tools/box/agent-run.ts @@ -1,25 +1,21 @@ import { z } from "zod"; import { tool } from "../helpers"; -import { boxCommon } from "./common"; +import { buildBoxCommon } from "./common"; import { getBoxClient } from "./utils"; import type { RunResponse } from "./types"; 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.`, - inputSchema: 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"), - ...boxCommon, - }), + 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); @@ -30,9 +26,7 @@ export const boxAgentRunTool = { const response = await client.post(`v2/box/${box_id}/run`, body); - const result: string[] = [ - `Agent run completed`, - ]; + const result: string[] = [`Agent run completed`]; if (response.run_id) { result.push(`Run ID: ${response.run_id}`); diff --git a/src/tools/box/common.ts b/src/tools/box/common.ts index 36e7fee..a845128 100644 --- a/src/tools/box/common.ts +++ b/src/tools/box/common.ts @@ -1,11 +1,24 @@ import { z } from "zod"; +import { config } from "../../config"; const BOX_BASE_URL = "https://us-east-1.box.upstash.com"; -export const boxCommon = { - box_api_key: z - .string() - .describe("Box API key (starts with 'box_' or 'abx_')"), -}; +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 index 748145a..0bbff15 100644 --- a/src/tools/box/exec.ts +++ b/src/tools/box/exec.ts @@ -1,27 +1,26 @@ import { z } from "zod"; import { tool } from "../helpers"; -import { boxCommon } from "./common"; +import { buildBoxCommon } from "./common"; import { getBoxClient } from "./utils"; import type { ExecResponse } from "./types"; 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.`, - inputSchema: 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"), - ...boxCommon, - }), + 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); diff --git a/src/tools/box/logs.ts b/src/tools/box/logs.ts index aa6b0e3..811abf1 100644 --- a/src/tools/box/logs.ts +++ b/src/tools/box/logs.ts @@ -1,45 +1,44 @@ import { z } from "zod"; import { json, tool } from "../helpers"; -import { boxCommon } from "./common"; +import { buildBoxCommon } from "./common"; import { getBoxClient } from "./utils"; import type { BoxLogEntry } from "./types"; 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.`, - inputSchema: 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)"), - ...boxCommon, - }), + 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: BoxLogEntry[] }>( - `v2/box/${box_id}/logs`, - { offset, limit } - ); + const response = await client.get<{ logs: BoxLogEntry[] }>(`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), - ]; + return [`Found ${logs.length} log entries`, json(logs)]; }, }), }; diff --git a/src/tools/box/manage.ts b/src/tools/box/manage.ts index 009b181..d14d8ad 100644 --- a/src/tools/box/manage.ts +++ b/src/tools/box/manage.ts @@ -1,67 +1,62 @@ import { z } from "zod"; import { json, tool } from "../helpers"; -import { boxCommon } from "./common"; +import { buildBoxCommon } from "./common"; import { getBoxClient } from "./utils"; import type { Box } from "./types"; 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.`, - inputSchema: 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'"), - ...boxCommon, - }), + 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); @@ -96,19 +91,13 @@ export const boxManageTool = { 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), - ]; + 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), - ]; + return [`Box ${box_id} (status: ${box.status})`, json(box)]; } case "delete": { @@ -132,11 +121,7 @@ export const boxManageTool = { 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), - ]; + return [`Box forked successfully`, `New Box ID: ${forked.id}`, json(forked)]; } default: { diff --git a/src/tools/box/preview.ts b/src/tools/box/preview.ts index 526fe6d..56c29ea 100644 --- a/src/tools/box/preview.ts +++ b/src/tools/box/preview.ts @@ -1,33 +1,33 @@ import { z } from "zod"; import { json, tool } from "../helpers"; -import { boxCommon } from "./common"; +import { buildBoxCommon } from "./common"; import { getBoxClient } from "./utils"; import type { BoxPreview, CreatePreviewResponse } from "./types"; 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.`, - inputSchema: 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)"), - ...boxCommon, - }), + 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); @@ -55,14 +55,9 @@ export const boxPreviewTool = { } case "list": { - const response = await client.get<{ previews: BoxPreview[] }>( - `v2/box/${box_id}/preview` - ); + const response = await client.get<{ previews: BoxPreview[] }>(`v2/box/${box_id}/preview`); const previews = response.previews ?? []; - return [ - `Found ${previews.length} preview URLs`, - json(previews), - ]; + return [`Found ${previews.length} preview URLs`, json(previews)]; } case "delete": { diff --git a/src/tools/box/runs.ts b/src/tools/box/runs.ts index ffeea07..7851e07 100644 --- a/src/tools/box/runs.ts +++ b/src/tools/box/runs.ts @@ -1,23 +1,20 @@ import { z } from "zod"; import { json, tool } from "../helpers"; -import { boxCommon } from "./common"; +import { buildBoxCommon } from "./common"; import { getBoxClient } from "./utils"; import type { BoxRun } from "./types"; 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.`, - inputSchema: 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)"), - ...boxCommon, - }), + 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); @@ -26,19 +23,13 @@ export const boxRunsTool = { case "list": { const response = await client.get<{ runs: BoxRun[] }>(`v2/box/${box_id}/runs`); const runs = response.runs ?? []; - return [ - `Found ${runs.length} runs`, - json(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), - ]; + return [`Run ${run_id} (status: ${run.status})`, json(run)]; } case "cancel": { diff --git a/src/tools/box/snapshots.ts b/src/tools/box/snapshots.ts index d4104a5..aab2e52 100644 --- a/src/tools/box/snapshots.ts +++ b/src/tools/box/snapshots.ts @@ -1,51 +1,47 @@ import { z } from "zod"; import { json, tool } from "../helpers"; -import { boxCommon } from "./common"; +import { buildBoxCommon } from "./common"; import { getBoxClient } from "./utils"; import type { Box, BoxSnapshot } from "./types"; 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.`, - inputSchema: 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)"), - ...boxCommon, - }), + 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); @@ -56,10 +52,7 @@ export const boxSnapshotsTool = { const body: Record = {}; if (params.name) body.name = params.name; - const snapshot = await client.post( - `v2/box/${box_id}/snapshots`, - body - ); + const snapshot = await client.post(`v2/box/${box_id}/snapshots`, body); return [ `Snapshot created (status: ${snapshot.status})`, `Snapshot ID: ${snapshot.id}`, @@ -73,19 +66,13 @@ export const boxSnapshotsTool = { `v2/box/${box_id}/snapshots` ); const snapshots = response.snapshots ?? []; - return [ - `Found ${snapshots.length} snapshots for box ${box_id}`, - json(snapshots), - ]; + return [`Found ${snapshots.length} snapshots for box ${box_id}`, json(snapshots)]; } case "list_all": { const response = await client.get<{ snapshots: BoxSnapshot[] }>("v2/box/snapshots"); const snapshots = response.snapshots ?? []; - return [ - `Found ${snapshots.length} snapshots total`, - json(snapshots), - ]; + return [`Found ${snapshots.length} snapshots total`, json(snapshots)]; } case "delete": { diff --git a/src/tools/box/utils.ts b/src/tools/box/utils.ts index 51aaf67..1e80893 100644 --- a/src/tools/box/utils.ts +++ b/src/tools/box/utils.ts @@ -1,9 +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 { +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: params.box_api_key, + qstashToken: apiKey, }); } From c77b157c38fe003a63fa6908d269e2e67ff1550a Mon Sep 17 00:00:00 2001 From: ytkimirti Date: Wed, 15 Apr 2026 13:11:32 +0300 Subject: [PATCH 5/8] docs(box): document optional Box API key for install paths Add a "Box support (optional)" section to the README with both CLI-flag and env-var install examples in mcpServers JSON format, and declare boxApiKey as a non-required property in smithery.yaml so Smithery's install UI renders it as optional. --- README.md | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ smithery.yaml | 5 +++++ 2 files changed, 56 insertions(+) diff --git a/README.md b/README.md index ccdc351..a042b6f 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,55 @@ Add this to your MCP client configuration: claude mcp add --transport stdio upstash -- npx -y @upstash/mcp-server@latest --email YOUR_EMAIL --api-key YOUR_API_KEY ``` +### Box support (optional) + +If you use [Upstash Box](https://upstash.com/docs/box), you can configure the Box API key at startup so you don't have to pass it on every tool call. This is **optional** โ€” Box tools still work without it (the agent will ask you for the key or read it from your `.env`). + +Pass it via the `--box-api-key` 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" + ] + } + } +} +``` + +Or set the `UPSTASH_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" + } + } + } +} +``` + ### Streamable HTTP Transport (for web applications) Start your MCP server with the `http` transport: @@ -102,6 +151,8 @@ For testing, you can create a `.env` file in the same directory as the project w ```bash UPSTASH_EMAIL= UPSTASH_API_KEY= +# Optional, for Box tools: +UPSTASH_BOX_API_KEY= ``` To install the local MCP Server to Claude Code, run: 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" From fe5bee99d31850a09ac6dc805b9754fc09311790 Mon Sep 17 00:00:00 2001 From: ytkimirti Date: Wed, 15 Apr 2026 21:00:14 +0300 Subject: [PATCH 6/8] refactor(box): narrow response types to fields actually read Inline minimal shapes at each tool's usage site and delete types.ts. The central interfaces had drifted from the upstream Box SDK (missing attachHeaders, mismatched git fields) while declaring 20+ fields that nothing ever read. --- src/tools/box/agent-run.ts | 6 +- src/tools/box/exec.ts | 2 +- src/tools/box/logs.ts | 3 +- src/tools/box/manage.ts | 10 +-- src/tools/box/preview.ts | 10 ++- src/tools/box/runs.ts | 6 +- src/tools/box/snapshots.ts | 11 ++-- src/tools/box/types.ts | 125 ------------------------------------- 8 files changed, 29 insertions(+), 144 deletions(-) delete mode 100644 src/tools/box/types.ts diff --git a/src/tools/box/agent-run.ts b/src/tools/box/agent-run.ts index 44186fc..b90af11 100644 --- a/src/tools/box/agent-run.ts +++ b/src/tools/box/agent-run.ts @@ -2,7 +2,11 @@ import { z } from "zod"; import { tool } from "../helpers"; import { buildBoxCommon } from "./common"; import { getBoxClient } from "./utils"; -import type { RunResponse } from "./types"; +type RunResponse = { + run_id?: string; + output?: string; + metadata?: { input_tokens?: number; output_tokens?: number; cost_usd?: number }; +}; export const boxAgentRunTool = { box_agent_run: tool({ diff --git a/src/tools/box/exec.ts b/src/tools/box/exec.ts index 0bbff15..92c524a 100644 --- a/src/tools/box/exec.ts +++ b/src/tools/box/exec.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import { tool } from "../helpers"; import { buildBoxCommon } from "./common"; import { getBoxClient } from "./utils"; -import type { ExecResponse } from "./types"; +type ExecResponse = { exit_code: number; output?: string; error?: string }; export const boxExecTool = { box_exec: tool({ diff --git a/src/tools/box/logs.ts b/src/tools/box/logs.ts index 811abf1..78e80b3 100644 --- a/src/tools/box/logs.ts +++ b/src/tools/box/logs.ts @@ -2,7 +2,6 @@ import { z } from "zod"; import { json, tool } from "../helpers"; import { buildBoxCommon } from "./common"; import { getBoxClient } from "./utils"; -import type { BoxLogEntry } from "./types"; export const boxLogsTool = { box_logs: tool({ @@ -28,7 +27,7 @@ export const boxLogsTool = { const { box_id, offset, limit } = params; const client = getBoxClient(params); - const response = await client.get<{ logs: BoxLogEntry[] }>(`v2/box/${box_id}/logs`, { + const response = await client.get<{ logs: unknown[] }>(`v2/box/${box_id}/logs`, { offset, limit, }); diff --git a/src/tools/box/manage.ts b/src/tools/box/manage.ts index d14d8ad..b5223fa 100644 --- a/src/tools/box/manage.ts +++ b/src/tools/box/manage.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import { json, tool } from "../helpers"; import { buildBoxCommon } from "./common"; import { getBoxClient } from "./utils"; -import type { Box } from "./types"; +type BoxRef = { id: string; status: string }; export const boxManageTool = { box_manage: tool({ @@ -79,7 +79,7 @@ export const boxManageTool = { if (params.ephemeral !== undefined) body.ephemeral = params.ephemeral; if (params.ttl !== undefined) body.ttl = params.ttl; - const box = await client.post("v2/box", body); + const box = await client.post("v2/box", body); return [ `Box created successfully (status: ${box.status})`, `Box ID: ${box.id}`, @@ -90,13 +90,13 @@ export const boxManageTool = { case "list": { const query: Record = {}; if (params.status === "deleted") query.status = "deleted"; - const boxes = await client.get("v2/box", query); + 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}`); + const box = await client.get(`v2/box/${box_id}`); return [`Box ${box_id} (status: ${box.status})`, json(box)]; } @@ -120,7 +120,7 @@ export const boxManageTool = { 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`); + const forked = await client.post(`v2/box/${box_id}/fork`); return [`Box forked successfully`, `New Box ID: ${forked.id}`, json(forked)]; } diff --git a/src/tools/box/preview.ts b/src/tools/box/preview.ts index 56c29ea..2800d8e 100644 --- a/src/tools/box/preview.ts +++ b/src/tools/box/preview.ts @@ -2,7 +2,13 @@ import { z } from "zod"; import { json, tool } from "../helpers"; import { buildBoxCommon } from "./common"; import { getBoxClient } from "./utils"; -import type { BoxPreview, CreatePreviewResponse } from "./types"; +type CreatePreviewResponse = { + url: string; + port: number; + username?: string; + password?: string; + token?: string; +}; export const boxPreviewTool = { box_preview: tool({ @@ -55,7 +61,7 @@ export const boxPreviewTool = { } case "list": { - const response = await client.get<{ previews: BoxPreview[] }>(`v2/box/${box_id}/preview`); + const response = await client.get<{ previews: unknown[] }>(`v2/box/${box_id}/preview`); const previews = response.previews ?? []; return [`Found ${previews.length} preview URLs`, json(previews)]; } diff --git a/src/tools/box/runs.ts b/src/tools/box/runs.ts index 7851e07..d16d77e 100644 --- a/src/tools/box/runs.ts +++ b/src/tools/box/runs.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import { json, tool } from "../helpers"; import { buildBoxCommon } from "./common"; import { getBoxClient } from "./utils"; -import type { BoxRun } from "./types"; +type RunRef = { status: string }; export const boxRunsTool = { box_runs: tool({ @@ -21,14 +21,14 @@ export const boxRunsTool = { switch (action) { case "list": { - const response = await client.get<{ runs: BoxRun[] }>(`v2/box/${box_id}/runs`); + 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}`); + const run = await client.get(`v2/box/${box_id}/runs/${run_id}`); return [`Run ${run_id} (status: ${run.status})`, json(run)]; } diff --git a/src/tools/box/snapshots.ts b/src/tools/box/snapshots.ts index aab2e52..f006b27 100644 --- a/src/tools/box/snapshots.ts +++ b/src/tools/box/snapshots.ts @@ -2,7 +2,8 @@ import { z } from "zod"; import { json, tool } from "../helpers"; import { buildBoxCommon } from "./common"; import { getBoxClient } from "./utils"; -import type { Box, BoxSnapshot } from "./types"; +type BoxRef = { id: string; status: string }; +type SnapshotRef = { id: string; status: string }; export const boxSnapshotsTool = { box_snapshots: tool({ @@ -52,7 +53,7 @@ export const boxSnapshotsTool = { const body: Record = {}; if (params.name) body.name = params.name; - const snapshot = await client.post(`v2/box/${box_id}/snapshots`, body); + const snapshot = await client.post(`v2/box/${box_id}/snapshots`, body); return [ `Snapshot created (status: ${snapshot.status})`, `Snapshot ID: ${snapshot.id}`, @@ -62,7 +63,7 @@ export const boxSnapshotsTool = { case "list": { if (!box_id) throw new Error("box_id is required for list action"); - const response = await client.get<{ snapshots: BoxSnapshot[] }>( + const response = await client.get<{ snapshots: SnapshotRef[] }>( `v2/box/${box_id}/snapshots` ); const snapshots = response.snapshots ?? []; @@ -70,7 +71,7 @@ export const boxSnapshotsTool = { } case "list_all": { - const response = await client.get<{ snapshots: BoxSnapshot[] }>("v2/box/snapshots"); + const response = await client.get<{ snapshots: SnapshotRef[] }>("v2/box/snapshots"); const snapshots = response.snapshots ?? []; return [`Found ${snapshots.length} snapshots total`, json(snapshots)]; } @@ -95,7 +96,7 @@ export const boxSnapshotsTool = { 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); + const box = await client.post("v2/box/from-snapshot", body); return [ `Box restored from snapshot (status: ${box.status})`, `New Box ID: ${box.id}`, diff --git a/src/tools/box/types.ts b/src/tools/box/types.ts deleted file mode 100644 index b82ebfd..0000000 --- a/src/tools/box/types.ts +++ /dev/null @@ -1,125 +0,0 @@ -export interface Box { - id: string; - customer_id: string; - name?: string; - model: string; - agent?: string; - runtime?: string; - status: string; - session_id?: string; - clone_repo?: string; - ephemeral?: boolean; - expires_at?: number; - total_input_tokens: number; - total_output_tokens: number; - total_prompts: number; - total_cpu_ns: number; - total_compute_cost_usd: number; - total_token_cost_usd: number; - use_managed_key: boolean; - mcp_servers?: McpServer[]; - enabled_skills?: string[]; - env_vars?: Record; - network_policy?: NetworkPolicy; - git_user_name?: string; - git_user_email?: string; - last_activity_at?: number; - created_at: number; - updated_at: number; -} - -export interface McpServer { - name: string; - source: string; - package_or_url: string; - args?: string[]; - headers?: Record; - enabled?: boolean; -} - -export interface NetworkPolicy { - mode: string; - allowed_domains?: string[]; - allowed_cidrs?: string[]; - denied_cidrs?: string[]; -} - -export interface ExecResponse { - exit_code: number; - output: string; - error?: string; - cpu_ns?: number; -} - -export interface RunResponse { - run_id?: string; - output: string; - metadata?: RunMetadata; -} - -export interface RunMetadata { - input_tokens?: number; - output_tokens?: number; - cached_input_tokens?: number; - cost_usd?: number; -} - -export interface BoxLogEntry { - timestamp: number; - level: string; - source: string; - message: string; -} - -export interface BoxRun { - id: string; - box_id: string; - type: string; - status: string; - prompt?: string; - model?: string; - output?: string; - input_tokens: number; - output_tokens: number; - cached_input_tokens?: number; - cost_usd: number; - duration_ms: number; - cpu_ns?: number; - compute_cost_usd?: number; - memory_peak_bytes?: number; - error_message?: string; - session_id?: string; - schedule_id?: string; - created_at: number; - completed_at?: number; -} - -export interface BoxPreview { - id: string; - box_id: string; - port: number; - username?: string; - password?: string; - token?: string; - created_at: number; -} - -export interface CreatePreviewResponse { - url: string; - port: number; - username?: string; - password?: string; - token?: string; -} - -export interface BoxSnapshot { - id: string; - box_id: string; - name: string; - runtime?: string; - model?: string; - ephemeral?: boolean; - size_bytes: number; - status: string; - created_at: number; -} From 125cad6ad793a3058d5de43769f42970c2137d36 Mon Sep 17 00:00:00 2001 From: ytkimirti Date: Thu, 16 Apr 2026 14:14:22 +0300 Subject: [PATCH 7/8] docs: rewrite README with per-client install guides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure the README around per-client install instructions (Claude Code, Cursor, Windsurf, OpenCode, Codex, VS Code, Antigravity, Claude Desktop, Gemini CLI), document the optional Upstash Box API key wiring, and expand the debugging/telemetry sections. Fix the Cursor one-click install deeplink โ€” the previous payload used a single `command` string, but Cursor expects `command` + `args` split. Also drop the Smithery badge, drop the redundant `--transport stdio` flag from the Claude Code install command, and add `--scope user` so the install lands in the user-level config by default. --- README.md | 315 ++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 268 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index a042b6f..e8f40aa 100644 --- a/README.md +++ b/README.md @@ -1,38 +1,47 @@ -# Upstash MCP +# Upstash MCP Server -[![Install MCP Server](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/en/install-mcp?name=upstash&config=eyJjb21tYW5kIjoibnB4IC15IEB1cHN0YXNoL21jcC1zZXJ2ZXJAbGF0ZXN0IC0tZW1haWwgWU9VUl9FTUFJTCAtLWFwaS1rZXkgWU9VUl9BUElfS0VZIn0%3D) +[![Install MCP Server](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/en/install-mcp?name=upstash&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkB1cHN0YXNoL21jcC1zZXJ2ZXJAbGF0ZXN0IiwiLS1lbWFpbCIsIllPVVJfRU1BSUwiLCItLWFwaS1rZXkiLCJZT1VSX0FQSV9LRVkiXX0%3D) +[Install in VS Code](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) -[Install in VS Code (npx)](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)**. -[![smithery badge](https://smithery.ai/badge/@upstash/mcp-server)](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 +``` + +> [!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. -- "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" +
+Claude Code -# Usage +Run this command in your terminal. See the [Claude Code MCP docs](https://docs.anthropic.com/en/docs/claude-code/mcp) for more info. -## Quick Setup +```sh +claude mcp add --scope user upstash -- npx -y @upstash/mcp-server@latest --email YOUR_EMAIL --api-key YOUR_API_KEY +``` -First, get your Upstash credentials: +
-- **Email**: Your Upstash account email -- **API Key**: Get it from [Upstash Console โ†’ Account โ†’ API Keys](https://console.upstash.com/account/api) +
+Cursor -Add this to your MCP client configuration: +Go to `Settings` โ†’ `Cursor Settings` โ†’ `MCP` โ†’ `Add new global MCP server`. + +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. + +[![Install MCP Server](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/en/install-mcp?name=upstash&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkB1cHN0YXNoL21jcC1zZXJ2ZXJAbGF0ZXN0IiwiLS1lbWFpbCIsIllPVVJfRU1BSUwiLCItLWFwaS1rZXkiLCJZT1VSX0FQSV9LRVkiXX0%3D) ```json { @@ -52,17 +61,228 @@ 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" + ] + } + } +} +``` + +
+ +
+OpenCode + +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 +``` + +> [!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): + +[Install in VS Code](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) +[Install in VS Code Insiders](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}" + ] + } + } +} ``` -### Box support (optional) +
-If you use [Upstash Box](https://upstash.com/docs/box), you can configure the Box API key at startup so you don't have to pass it on every tool call. This is **optional** โ€” Box tools still work without it (the agent will ask you for the key or read it from your `.env`). +
+Google Antigravity -Pass it via the `--box-api-key` CLI flag: +Add this to your Antigravity MCP config. See the [Antigravity MCP docs](https://antigravity.google/docs/mcp) for more info. + +```json +{ + "mcpServers": { + "upstash": { + "command": "npx", + "args": [ + "-y", + "@upstash/mcp-server@latest", + "--email", + "YOUR_EMAIL", + "--api-key", + "YOUR_API_KEY" + ] + } + } +} +``` + +
+ +
+Claude Desktop + +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. + +```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 { @@ -84,7 +304,7 @@ Pass it via the `--box-api-key` CLI flag: } ``` -Or set the `UPSTASH_BOX_API_KEY` environment variable: +**Environment variable** ```json { @@ -107,33 +327,34 @@ Or set the `UPSTASH_BOX_API_KEY` environment variable: } ``` -### Streamable HTTP Transport (for web applications) - -Start your MCP server with the `http` transport: +## Debugging -```bash -npx @upstash/mcp-server@latest --transport http --port 3000 --email YOUR_EMAIL --api-key YOUR_API_KEY -``` - -And configure your MCP client to use the HTTP transport: +If the server is misbehaving or a tool keeps failing, enable verbose logging with the `--debug` flag: ```json { "mcpServers": { "upstash": { - "url": "http://localhost:3000/mcp" + "command": "npx", + "args": [ + "-y", + "@upstash/mcp-server@latest", + "--email", + "YOUR_EMAIL", + "--api-key", + "YOUR_API_KEY", + "--debug" + ] } } } ``` -## Telemetry +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). -The server sends anonymous runtime/platform info to Upstash with each request. To opt out, add `--disable-telemetry` to your args. - -## Troubleshooting +## 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 @@ -144,9 +365,9 @@ 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= @@ -155,13 +376,13 @@ UPSTASH_API_KEY= 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 From dc969093852ceba02cda298958325f15d9a4738f Mon Sep 17 00:00:00 2001 From: ytkimirti Date: Thu, 16 Apr 2026 15:20:04 +0300 Subject: [PATCH 8/8] fix(release): detect prereleases from event and strip v prefix Route prerelease tags (e.g. v0.2.3-rc.1) to the canary publish step via github.event.release.prerelease, since inputs.* is unset for release events. Also strip the leading v from VERSION so package.json gets a valid semver, and drop ./ from the bin path to silence npm's warning. --- .github/workflows/release.yaml | 8 +++----- package.json | 2 +- 2 files changed, 4 insertions(+), 6 deletions(-) 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/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",