diff --git a/package-lock.json b/package-lock.json index abed431..4b4d168 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,7 @@ "": { "name": "mcp-server", "version": "0.0.0", - "license": "ISC", + "license": "Apache-2.0", "dependencies": { "@gram-ai/functions": "^0.12.1", "@modelcontextprotocol/sdk": "^1.20.1", diff --git a/src/gram.ts b/src/gram.ts index 9459ebf..050a881 100644 --- a/src/gram.ts +++ b/src/gram.ts @@ -6,6 +6,7 @@ import { executeWriteQueryGram } from "./tools/execute-write-query.ts"; import { getInsightsGram } from "./tools/get-insights.ts"; import { listClusterSizesGram } from "./tools/list-cluster-sizes.ts"; import { searchDocumentationGram } from "./tools/search-documentation.ts"; +import { trafficControlGram } from "./tools/traffic-control.ts"; const gram = new Gram({ envSchema: { @@ -25,6 +26,7 @@ const gram = new Gram({ .extend(executeWriteQueryGram) .extend(getInsightsGram) .extend(listClusterSizesGram) - .extend(searchDocumentationGram); + .extend(searchDocumentationGram) + .extend(trafficControlGram); export default gram; diff --git a/src/lib/planetscale-api.ts b/src/lib/planetscale-api.ts index b5957fa..0fcb5d0 100644 --- a/src/lib/planetscale-api.ts +++ b/src/lib/planetscale-api.ts @@ -18,6 +18,12 @@ export interface Branch { has_replicas: boolean; } +export interface Actor { + id: string; + display_name: string; + avatar_url: string; +} + export interface VitessCredentials { id: string; username: string; @@ -74,7 +80,7 @@ export class PlanetScaleAPIError extends Error { async function apiRequest( endpoint: string, authHeader: string, - options: RequestInit = {} + options: RequestInit = {}, ): Promise { const url = `${API_BASE}${endpoint}`; @@ -99,7 +105,7 @@ async function apiRequest( throw new PlanetScaleAPIError( "Resource not found. Please check your organization, database, and branch names.", response.status, - details + details, ); } @@ -107,14 +113,14 @@ async function apiRequest( throw new PlanetScaleAPIError( "Permission denied. Please check your API token has the required permissions.", response.status, - details + details, ); } throw new PlanetScaleAPIError( `API request failed: ${response.statusText}`, response.status, - details + details, ); } @@ -132,11 +138,11 @@ async function apiRequest( export async function getDatabase( organization: string, database: string, - authHeader: string + authHeader: string, ): Promise { return apiRequest( `/organizations/${encodeURIComponent(organization)}/databases/${encodeURIComponent(database)}`, - authHeader + authHeader, ); } @@ -147,11 +153,11 @@ export async function getBranch( organization: string, database: string, branch: string, - authHeader: string + authHeader: string, ): Promise { return apiRequest( `/organizations/${encodeURIComponent(organization)}/databases/${encodeURIComponent(database)}/branches/${encodeURIComponent(branch)}`, - authHeader + authHeader, ); } @@ -164,7 +170,7 @@ export async function createVitessCredentials( branch: string, role: VitessRole, authHeader: string, - replica?: boolean + replica?: boolean, ): Promise { const timestamp = Date.now(); const name = `mcp-query-${timestamp}`; @@ -189,7 +195,7 @@ export async function createVitessCredentials( ttl: 60, // 60 seconds TTL replica: replica ?? false, }), - } + }, ); return { @@ -209,7 +215,7 @@ export async function createPostgresCredentials( database: string, branch: string, inheritedRoles: PostgresInheritedRole[], - authHeader: string + authHeader: string, ): Promise { const timestamp = Date.now(); const name = `mcp-query-${timestamp}`; @@ -234,7 +240,7 @@ export async function createPostgresCredentials( inherited_roles: inheritedRoles, ttl: 60, // 60 seconds TTL }), - } + }, ); return { @@ -259,15 +265,17 @@ export async function deletePostgresRole( branch: string, roleId: string, authHeader: string, - options?: { successor?: string } + options?: { successor?: string }, ): Promise { await apiRequest( `/organizations/${encodeURIComponent(organization)}/databases/${encodeURIComponent(database)}/branches/${encodeURIComponent(branch)}/roles/${encodeURIComponent(roleId)}`, authHeader, { method: "DELETE", - body: options?.successor ? JSON.stringify({ successor: options.successor }) : undefined, - } + body: options?.successor + ? JSON.stringify({ successor: options.successor }) + : undefined, + }, ); } @@ -279,11 +287,202 @@ export async function deleteVitessPassword( database: string, branch: string, passwordId: string, - authHeader: string + authHeader: string, ): Promise { await apiRequest( `/organizations/${encodeURIComponent(organization)}/databases/${encodeURIComponent(database)}/branches/${encodeURIComponent(branch)}/passwords/${encodeURIComponent(passwordId)}`, authHeader, - { method: "DELETE" } + { method: "DELETE" }, + ); +} + +// --- Traffic Control --- + +export interface TrafficRuleTag { + key_id: string; + key: string; + value: string; + source: "sql" | "system"; +} + +export interface TrafficRule { + id: string; + kind: "match"; + tags: TrafficRuleTag[]; + fingerprint: string; + keyspace: string; + actor: Actor; + syntax_highlighted_sql: string; + created_at: string; +} + +export type TrafficBudgetMode = "enforce" | "warn" | "off"; + +export interface TrafficBudget { + id: string; + name: string; + mode: TrafficBudgetMode; + capacity: number | null; + rate: number | null; + burst: number | null; + concurrency: number | null; + actor: Actor; + rules: TrafficRule[]; + created_at: string; + updated_at: string; +} + +export interface PaginatedResponse { + current_page: number; + next_page: number | null; + next_page_url: string | null; + prev_page: number | null; + prev_page_url: string | null; + data: T[]; +} + +function branchPath( + organization: string, + database: string, + branch: string, +): string { + return `/organizations/${encodeURIComponent(organization)}/databases/${encodeURIComponent(database)}/branches/${encodeURIComponent(branch)}`; +} + +export async function listTrafficBudgets( + organization: string, + database: string, + branch: string, + authHeader: string, + opts?: { + page?: number; + per_page?: number; + period?: string; + fingerprint?: string; + created_at?: string; + }, +): Promise> { + const params = new URLSearchParams(); + if (opts?.page) params.set("page", String(opts.page)); + if (opts?.per_page) params.set("per_page", String(opts.per_page)); + if (opts?.period) params.set("period", opts.period); + if (opts?.fingerprint) params.set("fingerprint", opts.fingerprint); + if (opts?.created_at) params.set("created_at", opts.created_at); + const qs = params.toString(); + return apiRequest>( + `${branchPath(organization, database, branch)}/traffic/budgets${qs ? `?${qs}` : ""}`, + authHeader, + ); +} + +export async function getTrafficBudget( + organization: string, + database: string, + branch: string, + id: string, + authHeader: string, +): Promise { + return apiRequest( + `${branchPath(organization, database, branch)}/traffic/budgets/${encodeURIComponent(id)}`, + authHeader, + ); +} + +export interface CreateTrafficBudgetInput { + name: string; + mode: TrafficBudgetMode; + capacity?: number; + rate?: number; + burst?: number; + concurrency?: number; + rules?: string[]; +} + +export async function createTrafficBudget( + organization: string, + database: string, + branch: string, + body: CreateTrafficBudgetInput, + authHeader: string, +): Promise { + return apiRequest( + `${branchPath(organization, database, branch)}/traffic/budgets`, + authHeader, + { method: "POST", body: JSON.stringify(body) }, + ); +} + +export interface UpdateTrafficBudgetInput { + name?: string; + mode?: TrafficBudgetMode; + capacity?: number; + rate?: number; + burst?: number; + concurrency?: number; + rules?: string[]; +} + +export async function updateTrafficBudget( + organization: string, + database: string, + branch: string, + id: string, + body: UpdateTrafficBudgetInput, + authHeader: string, +): Promise { + return apiRequest( + `${branchPath(organization, database, branch)}/traffic/budgets/${encodeURIComponent(id)}`, + authHeader, + { method: "PATCH", body: JSON.stringify(body) }, + ); +} + +export async function deleteTrafficBudget( + organization: string, + database: string, + branch: string, + id: string, + authHeader: string, +): Promise { + await apiRequest( + `${branchPath(organization, database, branch)}/traffic/budgets/${encodeURIComponent(id)}`, + authHeader, + { method: "DELETE" }, + ); +} + +export interface CreateTrafficRuleInput { + kind: "match"; + tags?: Array<{ key: string; value: string }>; + fingerprint?: string; +} + +export async function createTrafficRule( + organization: string, + database: string, + branch: string, + budgetId: string, + body: CreateTrafficRuleInput, + authHeader: string, +): Promise { + return apiRequest( + `${branchPath(organization, database, branch)}/traffic/budgets/${encodeURIComponent(budgetId)}/rules`, + authHeader, + { method: "POST", body: JSON.stringify(body) }, + ); +} + +export async function deleteTrafficRule( + organization: string, + database: string, + branch: string, + budgetId: string, + ruleId: string, + authHeader: string, +): Promise { + await apiRequest( + `${branchPath(organization, database, branch)}/traffic/budgets/${encodeURIComponent(budgetId)}/rules/${encodeURIComponent(ruleId)}`, + authHeader, + { method: "DELETE" }, ); } diff --git a/src/tools/traffic-control.ts b/src/tools/traffic-control.ts new file mode 100644 index 0000000..d6657d4 --- /dev/null +++ b/src/tools/traffic-control.ts @@ -0,0 +1,375 @@ +import { Gram } from "@gram-ai/functions"; +import { z } from "zod"; +import { + PlanetScaleAPIError, + listTrafficBudgets, + getTrafficBudget, + createTrafficBudget, + updateTrafficBudget, + deleteTrafficBudget, + createTrafficRule, + deleteTrafficRule, +} from "../lib/planetscale-api.ts"; +import { getAuthToken, getAuthHeader } from "../lib/auth.ts"; + +function getEnv(ctx: { + env: Record; +}): Record { + return Object.keys(ctx.env).length > 0 + ? (ctx.env as Record) + : process.env; +} + +function handleError( + ctx: { text: (s: string) => Response }, + error: unknown, +): Response { + if (error instanceof PlanetScaleAPIError) { + return ctx.text(`Error: ${error.message} (status: ${error.statusCode})`); + } + if (error instanceof Error) { + return ctx.text(`Error: ${error.message}`); + } + return ctx.text("Error: An unexpected error occurred"); +} + +const orgParam = z.string().describe("PlanetScale organization name"); +const dbParam = z.string().describe("Database name"); +const branchParam = z.string().describe("Branch name (e.g., 'main')"); + +export const trafficControlGram = new Gram() + .tool({ + name: "list_traffic_budgets", + description: + "List traffic control budgets for a PlanetScale database branch. " + + "Traffic budgets let you set rate limits and concurrency caps on query traffic. " + + "Returns a paginated list of budgets with their rules.", + inputSchema: { + organization: orgParam, + database: dbParam, + branch: branchParam, + page: z.number().optional().describe("Page number (default: 1)"), + per_page: z + .number() + .optional() + .describe("Results per page (default: 25)"), + period: z + .string() + .optional() + .describe("Time period filter (e.g., '1h', '24h', '7d')"), + fingerprint: z + .string() + .optional() + .describe("Filter budgets by query fingerprint"), + }, + async execute(ctx, input) { + try { + const env = getEnv(ctx); + if (!getAuthToken(env)) { + return ctx.text("Error: No PlanetScale authentication configured."); + } + const authHeader = getAuthHeader(env); + const result = await listTrafficBudgets( + input.organization, + input.database, + input.branch, + authHeader, + { + page: input.page, + per_page: input.per_page, + period: input.period, + fingerprint: input.fingerprint, + }, + ); + return ctx.json(result); + } catch (error) { + return handleError(ctx, error); + } + }, + }) + .tool({ + name: "get_traffic_budget", + description: + "Get a specific traffic control budget by ID, including its rules.", + inputSchema: { + organization: orgParam, + database: dbParam, + branch: branchParam, + id: z.string().describe("The ID of the traffic budget"), + }, + async execute(ctx, input) { + try { + const env = getEnv(ctx); + if (!getAuthToken(env)) { + return ctx.text("Error: No PlanetScale authentication configured."); + } + const authHeader = getAuthHeader(env); + const result = await getTrafficBudget( + input.organization, + input.database, + input.branch, + input.id, + authHeader, + ); + return ctx.json(result); + } catch (error) { + return handleError(ctx, error); + } + }, + }) + .tool({ + name: "create_traffic_budget", + description: + "Create a traffic control budget on a branch. A budget defines rate limits for matching query traffic. " + + "Set mode to 'enforce' to actively throttle, 'warn' to log without blocking, or 'off' to disable. " + + "capacity is the max banked capacity (% of seconds of full server usage, 0-6000; unlimited when unset). " + + "rate is the refill rate (% of server resources, 0-100; unlimited when unset). " + + "burst is the max a single query can consume (0-6000; unlimited when unset). " + + "concurrency is the % of available worker processes (0-100; unlimited when unset).", + inputSchema: { + organization: orgParam, + database: dbParam, + branch: branchParam, + name: z.string().describe("Name of the traffic budget"), + mode: z + .enum(["enforce", "warn", "off"]) + .describe( + "Budget mode: 'enforce' to throttle, 'warn' to log only, 'off' to disable", + ), + capacity: z + .number() + .optional() + .describe( + "Max banked capacity (0-6000, % of seconds of full server usage)", + ), + rate: z + .number() + .optional() + .describe("Capacity refill rate (0-100, % of server resources)"), + burst: z + .number() + .optional() + .describe("Max capacity a single query can consume (0-6000)"), + concurrency: z + .number() + .optional() + .describe("Max % of available worker processes (0-100)"), + rules: z + .array(z.string()) + .optional() + .describe("Array of traffic rule IDs to attach to this budget"), + }, + async execute(ctx, input) { + try { + const env = getEnv(ctx); + if (!getAuthToken(env)) { + return ctx.text("Error: No PlanetScale authentication configured."); + } + const authHeader = getAuthHeader(env); + const result = await createTrafficBudget( + input.organization, + input.database, + input.branch, + { + name: input.name, + mode: input.mode, + capacity: input.capacity, + rate: input.rate, + burst: input.burst, + concurrency: input.concurrency, + rules: input.rules, + }, + authHeader, + ); + return ctx.json(result); + } catch (error) { + return handleError(ctx, error); + } + }, + }) + .tool({ + name: "update_traffic_budget", + description: + "Update an existing traffic control budget. Any fields not provided are left unchanged.", + inputSchema: { + organization: orgParam, + database: dbParam, + branch: branchParam, + id: z.string().describe("The ID of the traffic budget to update"), + name: z.string().optional().describe("New name for the budget"), + mode: z + .enum(["enforce", "warn", "off"]) + .optional() + .describe( + "Budget mode: 'enforce' to throttle, 'warn' to log only, 'off' to disable", + ), + capacity: z + .number() + .optional() + .describe( + "Max banked capacity (0-6000, % of seconds of full server usage)", + ), + rate: z + .number() + .optional() + .describe("Capacity refill rate (0-100, % of server resources)"), + burst: z + .number() + .optional() + .describe("Max capacity a single query can consume (0-6000)"), + concurrency: z + .number() + .optional() + .describe("Max % of available worker processes (0-100)"), + rules: z + .array(z.string()) + .optional() + .describe("Array of traffic rule IDs to apply to the budget"), + }, + async execute(ctx, input) { + try { + const env = getEnv(ctx); + if (!getAuthToken(env)) { + return ctx.text("Error: No PlanetScale authentication configured."); + } + const authHeader = getAuthHeader(env); + const body: Record = {}; + if (input["name"] !== undefined) body["name"] = input["name"]; + if (input["mode"] !== undefined) body["mode"] = input["mode"]; + if (input["capacity"] !== undefined) + body["capacity"] = input["capacity"]; + if (input["rate"] !== undefined) body["rate"] = input["rate"]; + if (input["burst"] !== undefined) body["burst"] = input["burst"]; + if (input["concurrency"] !== undefined) + body["concurrency"] = input["concurrency"]; + if (input["rules"] !== undefined) body["rules"] = input["rules"]; + const result = await updateTrafficBudget( + input.organization, + input.database, + input.branch, + input.id, + body, + authHeader, + ); + return ctx.json(result); + } catch (error) { + return handleError(ctx, error); + } + }, + }) + .tool({ + name: "delete_traffic_budget", + description: "Delete a traffic control budget from a branch.", + inputSchema: { + organization: orgParam, + database: dbParam, + branch: branchParam, + id: z.string().describe("The ID of the traffic budget to delete"), + }, + async execute(ctx, input) { + try { + const env = getEnv(ctx); + if (!getAuthToken(env)) { + return ctx.text("Error: No PlanetScale authentication configured."); + } + const authHeader = getAuthHeader(env); + await deleteTrafficBudget( + input.organization, + input.database, + input.branch, + input.id, + authHeader, + ); + return ctx.json({ success: true }); + } catch (error) { + return handleError(ctx, error); + } + }, + }) + .tool({ + name: "add_traffic_rule", + description: + "Add a traffic rule to a budget. Rules match query traffic to a budget " + + "using tags (key/value pairs from SQL comments or system metadata) and/or a query fingerprint.", + inputSchema: { + organization: orgParam, + database: dbParam, + branch: branchParam, + budget_id: z + .string() + .describe("The ID of the traffic budget to add the rule to"), + kind: z + .enum(["match"]) + .describe("Rule kind (currently only 'match' is supported)"), + tags: z + .array( + z.object({ + key: z.string().describe("Tag key"), + value: z.string().describe("Tag value"), + }), + ) + .optional() + .describe( + "Tags to match against (from SQL comments or system metadata)", + ), + fingerprint: z + .string() + .optional() + .describe("Query fingerprint to target with this rule"), + }, + async execute(ctx, input) { + try { + const env = getEnv(ctx); + if (!getAuthToken(env)) { + return ctx.text("Error: No PlanetScale authentication configured."); + } + const authHeader = getAuthHeader(env); + const result = await createTrafficRule( + input.organization, + input.database, + input.branch, + input.budget_id, + { + kind: input.kind, + tags: input.tags, + fingerprint: input.fingerprint, + }, + authHeader, + ); + return ctx.json(result); + } catch (error) { + return handleError(ctx, error); + } + }, + }) + .tool({ + name: "delete_traffic_rule", + description: "Delete a traffic rule from a budget.", + inputSchema: { + organization: orgParam, + database: dbParam, + branch: branchParam, + budget_id: z.string().describe("The ID of the traffic budget"), + id: z.string().describe("The ID of the traffic rule to delete"), + }, + async execute(ctx, input) { + try { + const env = getEnv(ctx); + if (!getAuthToken(env)) { + return ctx.text("Error: No PlanetScale authentication configured."); + } + const authHeader = getAuthHeader(env); + await deleteTrafficRule( + input.organization, + input.database, + input.branch, + input.budget_id, + input.id, + authHeader, + ); + return ctx.json({ success: true }); + } catch (error) { + return handleError(ctx, error); + } + }, + });