Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
build:
name: Build & Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: oven-sh/setup-bun@v1
with:
bun-version: latest

- run: bun install

- run: bun run build

- run: bun run lint

test:
name: E2E Tests
runs-on: ubuntu-latest
needs: build
steps:
- uses: actions/checkout@v4

- uses: oven-sh/setup-bun@v1
with:
bun-version: latest

- run: bun install

- name: Run tests
env:
UPSTASH_EMAIL: ${{ secrets.UPSTASH_EMAIL }}
UPSTASH_API_KEY: ${{ secrets.UPSTASH_API_KEY }}
UPSTASH_API_KEY_READONLY: ${{ secrets.UPSTASH_API_KEY_READONLY }}
UPSTASH_BOX_API_KEY: ${{ secrets.UPSTASH_BOX_API_KEY }}
run: bun test
1 change: 1 addition & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ export const config = {
apiKey: "",
email: "",
Comment thread
ytkimirti marked this conversation as resolved.
disableTelemetry: false,
readonly: false,
};
174 changes: 174 additions & 0 deletions src/readonly.test.ts
Original file line number Diff line number Diff line change
@@ -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<any>
>;
const qstashAllTools = { ...qstashTools, ...workflowTools } as Record<string, CustomTool<any>>;

// 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<RedisDatabase[]>("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<RedisDatabase[]>("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();
}
});
});
7 changes: 6 additions & 1 deletion src/server.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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,
Expand Down
14 changes: 14 additions & 0 deletions src/test-connection.ts
Original file line number Diff line number Diff line change
@@ -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");

Expand All @@ -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");
}
6 changes: 6 additions & 0 deletions src/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ export type CustomTool<TSchema extends ZodSchema = ZodSchema> = {
*/
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.
Expand Down
53 changes: 53 additions & 0 deletions src/tools/box/agent-run.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { z } from "zod";
import { 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<string, unknown> = { prompt };
if (model) body.model = model;
if (folder) body.folder = folder;

const response = await client.post<RunResponse>(`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;
},
}),
};
Loading
Loading