Skip to content

Commit

Permalink
Revision 1 (#3)
Browse files Browse the repository at this point in the history
* fix: use node15 and es2015 when building

* fix: install node@15 types

* fix: improve stats

* fix: long scan loop when asking for keys

* chore: remove lint flag

* chore: remove .js suffixes from imports

* fix: add node15 target to tsup

* chore: remove unnecessary code

* fix: don't use Array.at for node15 support

* fix: add hint for list-dbs

* add a timestamps_to_date tool

* fix: add hint for truncate

* chore: move timestamp_to_date to utils

* fix: add hint for teams

* chore: rename commands

* fix: remove bun-types from tsconfig

* fix: modify hints for stats

* fix: remove dot from hint
  • Loading branch information
ytkimirti authored Jan 13, 2025
1 parent d2e7397 commit 916cc51
Show file tree
Hide file tree
Showing 15 changed files with 126 additions and 77 deletions.
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# Upstash MCP Server

[![smithery badge](https://smithery.ai/badge/@upstash/mcp-server-upstash)](https://smithery.ai/server/@upstash/mcp-server-upstash)

Model Context Protocol (MCP) is a [new, standardized protocol](https://modelcontextprotocol.io/introduction) for managing context between large language models (LLMs) and external systems. In this repository, we provide an installer as well as an MCP Server for [Upstash Developer API's](https://upstash.com/docs/devops/developer-api).
Expand All @@ -20,6 +21,7 @@ This lets you use Claude Desktop, or any MCP Client, to use natural language to
- [Upstash API key](https://upstash.com/docs/devops/developer-api) - You can create one from [here](https://console.upstash.com/account/api).

## How to use locally

### Installing via Smithery

To install Upstash for Claude Desktop automatically via [Smithery](https://smithery.ai/server/@upstash/mcp-server-upstash):
Expand All @@ -29,6 +31,7 @@ npx -y @smithery/cli install @upstash/mcp-server-upstash --client claude
```

### Installing manually

1. Run `npx @upstash/mcp-server-upstash init <UPSTASH_EMAIL> <UPSTASH_API_KEY>`
2. Restart Claude Desktop
3. You should now be able to use Upstash commands in Claude Desktop
Expand All @@ -54,7 +57,8 @@ See the [troubleshooting guide](https://modelcontextprotocol.io/quickstart#troub
- `redis_database_run_single_redis_command`
- `redis_database_set_daily_backup`
- `redis_database_update_regions`
- `redis_database_get_usage_stats`
- `redis_database_get_usage_last_5_days`
- `redis_database_get_stats`

## Development

Expand Down
Binary file modified bun.lockb
Binary file not shown.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,14 @@
"devDependencies": {
"@typescript-eslint/eslint-plugin": "8.4.0",
"@typescript-eslint/parser": "8.4.0",
"bun-types": "^1.1.38",
"eslint": "9.10.0",
"eslint-plugin-unicorn": "55.0.0",
"prettier": "^3.4.2",
"tsup": "^8.3.5"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.3",
"@types/node": "15",
"chalk": "^5.3.0",
"dotenv": "^16.4.7",
"node-fetch": "^3.3.2",
Expand Down
1 change: 1 addition & 0 deletions src/http.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { config } from "./config";
import { log } from "./log";
import { applyMiddlewares } from "./middlewares";
import type { RequestInit } from "node-fetch";
import fetch from "node-fetch";

export type UpstashRequest = {
Expand Down
11 changes: 5 additions & 6 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
#!/usr/bin/env node

/* eslint-disable unicorn/no-process-exit */
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { init } from "./init.js";
import { log } from "./log.js";
import { server } from "./server.js";
import { config } from "./config.js";
import { init } from "./init";
import { log } from "./log";
import { server } from "./server";
import { config } from "./config";
import "dotenv/config";
import { testConnection } from "./test-connection.js";
import { testConnection } from "./test-connection";

process.on("uncaughtException", (error) => {
log("Uncaught exception:", error.name, error.message, error.stack);
Expand Down
13 changes: 5 additions & 8 deletions src/init.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,12 @@
import path from "node:path";
import os from "node:os";
import fs from "node:fs";
/* eslint-disable unicorn/prefer-node-protocol */
import path from "path";
import os from "os";
import fs from "fs";
import chalk from "chalk";
import { fileURLToPath } from "node:url";

import { log } from "./log";
import { config } from "./config";

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const _packageJson = JSON.parse(
fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8")
);
const claudeConfigPath = path.join(
os.homedir(),
"Library",
Expand Down
2 changes: 1 addition & 1 deletion src/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export function handlerResponseToCallResult(
// Truncate messages that are too long
const truncatedArray = array.map((item) =>
item.length > MAX_MESSAGE_LENGTH
? `${item.slice(0, MAX_MESSAGE_LENGTH)}... (truncated)`
? `${item.slice(0, MAX_MESSAGE_LENGTH)}... (MESSAGE TRUNCATED, MENTION THIS TO USER)`
: item
);

Expand Down
9 changes: 8 additions & 1 deletion src/tools/redis/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,14 @@ NOTE: SCAN cursor [MATCH pattern] [COUNT count] [TYPE type]`,
throw new Error("Redis error: " + result.error);
}

return json(result);
const isScanCommand = command[0].toLocaleLowerCase().includes("scan");
const messages = [json(result)];

if (isScanCommand)
messages.push(`NOTE: Use the returned cursor to get the next set of keys.
NOTE: The result might be too large to be returned. If applicable, stop after the second SCAN command and ask the user if they want to continue.`);

return messages;
},
}),

Expand Down
125 changes: 72 additions & 53 deletions src/tools/redis/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { z } from "zod";
import { json, tool } from "..";
import { http } from "../../http";
import type { RedisDatabase, RedisUsageResponse, UsageData } from "./types";
import { pruneFalsy } from "../../utils";

const readRegionSchema = z.union([
z.literal("us-east-1"),
Expand All @@ -14,24 +15,20 @@ const readRegionSchema = z.union([
z.literal("sa-east-1"),
]);

const READ_REGIONS_DESCRIPTION =
"Available regions: us-east-1, us-west-1, us-west-2, eu-west-1, eu-central-1, ap-southeast-1, ap-southeast-2, sa-east-1";

const GENERIC_DATABASE_NOTES = "\nNOTE: Don't show the database ID from the response to the user unless explicitly asked or needed.\n";
const GENERIC_DATABASE_NOTES =
"\nNOTE: Don't show the database ID from the response to the user unless explicitly asked or needed.\n";

export const redisDbOpsTools = {
redis_database_create_new: tool({
description: `Create a new Upstash redis database.
NOTE: Ask user for the region and name of the database.${GENERIC_DATABASE_NOTES}`,
inputSchema: z.object({
name: z.string().describe("Name of the database."),
primary_region: readRegionSchema.describe(
`Primary Region of the Global Database. ${READ_REGIONS_DESCRIPTION}`
),
primary_region: readRegionSchema.describe(`Primary Region of the Global Database.`),
read_regions: z
.array(readRegionSchema)
.optional()
.describe(`Array of Read Regions of the Database. ${READ_REGIONS_DESCRIPTION}`),
.describe(`Array of read regions of the db`),
}),
handler: async ({ name, primary_region, read_regions }) => {
const newDb = await http.post<RedisDatabase>("v2/redis/database", {
Expand Down Expand Up @@ -61,32 +58,32 @@ NOTE: Ask user for the region and name of the database.${GENERIC_DATABASE_NOTES}
}),

redis_database_list_databases: tool({
description:
`List all Upstash redis databases. Includes names, regions, password, creation time and more.${GENERIC_DATABASE_NOTES}`,
description: `List all Upstash redis databases. Only their names and ids.${GENERIC_DATABASE_NOTES}`,
handler: async () => {
const dbs = await http.get<RedisDatabase[]>("v2/redis/databases");

return json(
// Only the important fields
dbs.map((db) => ({
database_id: db.database_id,
database_name: db.database_name,
database_type: db.database_type,
region: db.region,
type: db.type,
primary_region: db.primary_region,
read_regions: db.read_regions,
creation_time: db.creation_time,
budget: db.budget,
state: db.state,
password: db.password,
endpoint: db.endpoint,
rest_token: db.rest_token,
read_only_rest_token: db.read_only_rest_token,
db_acl_enabled: db.db_acl_enabled,
db_acl_default_user_status: db.db_acl_default_user_status,
}))
const messages = [
json(
dbs.map((db) => {
const result = {
database_id: db.database_id,
database_name: db.database_name,
state: db.state === "active" ? undefined : db.state,
};
return pruneFalsy(result);
})
),
];

if (dbs.length > 2)
messages.push(
`NOTE: If the user did not specify a database name for the next command, ask them to choose a database from the list.`
);
messages.push(
"NOTE: If the user wants to see dbs in another team, mention that they need to create a new management api key for that team and initialize MCP server with the newly created key."
);

return messages;
},
}),

Expand Down Expand Up @@ -140,9 +137,30 @@ ${GENERIC_DATABASE_NOTES}
},
}),

redis_database_get_usage_stats: tool({
description: `Get usage statistics of an Upstash redis database over a period of time.
Available stats: read_latency_mean, write_latency_mean, keyspace, throughput (cmds per second), daily_net_commands, diskusage, command_counts (stats of every command seperately).`,
redis_database_get_usage_last_5_days: tool({
description: `Get PRECISE command count and bandwidth usage statistics of an Upstash redis database over the last 5 days. This is a precise stat, not an average.
NOTE: Ask user first if they want to see stats for each database seperately or just for one.`,
inputSchema: z.object({
id: z.string().describe("The ID of your database."),
}),
handler: async ({ id }) => {
const stats = await http.get<RedisUsageResponse>(["v2/redis/stats", `${id}?period=3h`]);

return [
json({
days: stats.days,
command_usage: stats.dailyrequests,
bandwidth_usage: stats.bandwidths,
}),
`NOTE: Times are calculated according to UTC+0`,
];
},
}),

redis_database_get_stats: tool({
description: `Get SAMPLED usage statistics of an Upstash redis database over a period of time (1h, 3h, 12h, 1d, 3d, 7d). Use this to check for peak usages and latency problems.
Includes: read_latency_mean, write_latency_mean, keyspace, throughput (cmds/sec), diskusage
NOTE: If the user does not specify which stat to get, use throughput as default.`,
inputSchema: z.object({
id: z.string().describe("The ID of your database."),
period: z
Expand All @@ -159,11 +177,11 @@ Available stats: read_latency_mean, write_latency_mean, keyspace, throughput (cm
.union([
z.literal("read_latency_mean"),
z.literal("write_latency_mean"),
z.literal("keyspace"),
z.literal("throughput"),
z.literal("daily_net_commands"),
z.literal("diskusage"),
z.literal("command_counts"),
z.literal("keyspace").describe("Number of keys in db"),
z
.literal("throughput")
.describe("commands per second (sampled), calculate area for estimated count"),
z.literal("diskusage").describe("Current disk usage in bytes"),
])
.describe("The type of stat to get"),
}),
Expand All @@ -173,31 +191,32 @@ Available stats: read_latency_mean, write_latency_mean, keyspace, throughput (cm
`${id}?period=${period}`,
]);

if (type === "command_counts") {
return JSON.stringify(
stats.command_counts.map((c) => ({
command: c.metric_identifier,
...parseUsageData(c.data_points),
}))
);
}

const stat = stats[type];

if (Array.isArray(stat)) {
return JSON.stringify(parseUsageData(stat));
}
if (!Array.isArray(stat))
throw new Error(
`Invalid key provided: ${type}. Valid keys are: ${Object.keys(stats).join(", ")}`
);

return json(stats);
return [
JSON.stringify(parseUsageData(stat)),
`NOTE: Use the timestamps_to_date tool to parse timestamps if needed`,
`NOTE: Don't try to plot multiple stats in the same chart`,
];
},
}),
};

const parseUsageData = (data: UsageData) => {
if (!data) return "NO DATA";
if (!Array.isArray(data)) return "INVALID DATA";
if (data.length === 0 || data.length === 1) return "NO DATA";
const filteredData = data.filter((d) => d.x && d.y);
return {
start: data[0].x,
start: filteredData[0].x,
// last one can be null, so use the second last
end: data.at(-1)?.x || data.at(-2)?.x,
// eslint-disable-next-line unicorn/prefer-at
end: filteredData[filteredData.length - 1]?.x,
data: data.map((d) => [new Date(d.x).getTime(), d.y]),
};
};
2 changes: 2 additions & 0 deletions src/tools/redis/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { utilTools } from "../utils";
import { redisBackupTools } from "./backup";
import { redisCommandTools } from "./command";
import { redisDbOpsTools } from "./db";
Expand All @@ -6,4 +7,5 @@ export const redisTools = {
...redisDbOpsTools,
...redisBackupTools,
...redisCommandTools,
...utilTools,
};
5 changes: 5 additions & 0 deletions src/tools/redis/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,4 +65,9 @@ export type RedisUsageResponse = {
metric_identifier: string;
data_points: UsageData;
}[];

// For last 5 days
dailyrequests: UsageData;
bandwidths: UsageData;
days: string[];
};
14 changes: 14 additions & 0 deletions src/tools/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { z } from "zod";
import { tool } from ".";

export const utilTools = {
timestamps_to_date: tool({
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"),
}),
handler: async ({ timestamps }) => {
return timestamps.map((timestamp) => new Date(timestamp).toUTCString());
},
}),
}
3 changes: 3 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function pruneFalsy(obj: Record<string, any>) {
return Object.fromEntries(Object.entries(obj).filter(([, value]) => value));
}
9 changes: 3 additions & 6 deletions tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"compilerOptions": {
"lib": ["ESNext"],
"lib": ["ES2015"],
"module": "esnext",
"target": "esnext",
"target": "ES2015",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"noEmit": true,
Expand All @@ -12,9 +12,6 @@
"jsx": "react-jsx",
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"allowJs": true,
"types": [
"bun-types" // add Bun global
]
"allowJs": true
}
}
1 change: 1 addition & 0 deletions tsup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ export default defineConfig({
clean: true,
dts: true,
sourcemap: true,
target: "node15",
});

0 comments on commit 916cc51

Please sign in to comment.