diff --git a/.env.example b/.env.example index 2940507..8e2bf84 100644 --- a/.env.example +++ b/.env.example @@ -3,8 +3,6 @@ PARSEABLE_URL=http://localhost:8000 PARSEABLE_API_KEY=your-api-key # Optional tool defaults -PARSEABLE_DEFAULT_DATASET= -PARSEABLE_MAX_ROWS=1000 PARSEABLE_QUERY_TIMEOUT_MS=30000 # HTTP mode (used when running: node dist/server.js http) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f180bbb..04cf4ec 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,115 +1,61 @@ # Contributing -Thanks for helping improve `parseable-mcp-server`. This document covers the design principles, layout, and the practical mechanics of adding a tool or fixing a bug. - -## Design principles - -1. **Auth is the trust boundary.** If a user supplies credentials, they trust the MCP client. No env-var write-gates - MCP clients already show per-call approval UI. Re-introduce gates only for hosted/multi-tenant variants. -2. **Time-bounded queries are mandatory.** Never unbounded scans. Default window 60 minutes, max 1440. -3. **Row caps are enforced.** Default 100, hard max via `PARSEABLE_MAX_ROWS` (default 1000). -4. **One tool, one verb.** No mega-tools. -5. **Tool descriptions are user-facing.** They tell the calling client when to use the tool, not just what. -6. **No `ingest_event` tool.** Wrong shape, encourages garbage data - use Parseable's ingest API directly. -7. **No destructive tools** (`delete_*`). Force the Parseable UI for safety. Destructiveness is asymmetric. -8. **Loud descriptions on side-effecting tools.** `evaluate_alert` warns "MAY TRIGGER REAL NOTIFICATIONS" so callers confirm with the user first. -9. **Cross-platform first.** Tools-only over stdio is universal. Optional MCP features (elicitation, prompts, resources) only with a chat-Q&A fallback that works everywhere. - -## Parseable API gotchas - -Reference docs: -- [API Reference](https://www.parseable.com/docs/api) -- [Alerting](https://www.parseable.com/docs/user-guide/alerting) -- [RBAC](https://www.parseable.com/docs/user-guide/rbac) -- [PromQL](https://www.parseable.com/docs/user-guide/promql) -- [Retention](https://www.parseable.com/docs/user-guide/retention) -- [Installation modes (self-hosted)](https://www.parseable.com/docs/self-hosted/installation) -- [SQL Editor](https://www.parseable.com/docs/user-guide/sql-editor) - -Things we learned the hard way (not always obvious from docs): -- Query payload uses **camelCase** keys. Required: `query`, `startTime`, `endTime`. Optional: `sendNull`. -- Auth is **Basic** (username:password base64). Switch to PAT when Parseable ships it server-side. -- All paths prefixed `/api/v1` - except PromQL. -- **PromQL endpoints live under `/prometheus/api/v1/`** (different base). `start`/`end`/`time` params must be **unix epoch seconds** in practice - `query_promql` auto-converts RFC3339 → epoch before sending. -- Cluster endpoints (`/cluster/info`, `/cluster/metrics`) only exist on **distributed** Parseable. Standalone returns 404. The `classifyStatus` helper surfaces a clear hint. - -## File layout +`parseable-mcp-server` is a thin MCP-to-Parseable adapter. Parseable owns tool +definitions, validation, RBAC, execution, and edition-specific capabilities. +This repository must not duplicate tool implementations. -``` -src/ -├── server.ts # MCP wire-up, stdio transport, tool registration loop -├── config.ts # env loader -├── client.ts # Parseable HTTP client (Basic auth, AbortController timeout, error classification) -└── tools/ - ├── types.ts # ToolDef interface + jsonResult/errorResult helpers - ├── index.ts # tool registry - add new tools here - └── .ts # one file per tool -test/ -├── client.test.ts # parseErrorBody, classifyStatus, request behavior -├── client_methods.test.ts # parametrized: every client method's verb + URL + body -├── tools.test.ts # logic-heavy tools (query_sql, query_promql, explain_query) -├── tools_passthrough.test.ts # every pass-through tool delegates correctly -├── registry.test.ts # meta: unique names, snake_case, no destructive tools -├── config.test.ts # env vars, defaults, overrides -└── types.test.ts # jsonResult / errorResult helpers -``` - -## Adding a tool - -1. Create `src/tools/.ts` exporting a `ToolDef`. -2. Append to the array in `src/tools/index.ts`. The server auto-registers everything in that array. -3. Add a HTTP method to `src/client.ts` if a new endpoint is involved. -4. Add a test entry to `test/client_methods.test.ts` (verb + URL). -5. Add a pass-through test to `test/tools_passthrough.test.ts` for boring tools, or a logic test in `test/tools.test.ts` for tools with their own behavior. -6. `npm run lint && npm test && npm run build`. - -Tool description guidelines: -- Tell the caller *when* to use the tool, not just what. -- Mention prerequisite tools (e.g. "Use `list_datasets` first to discover names"). -- Loud-warn on side effects: "MAY FIRE REAL NOTIFICATIONS", "Destructive - confirm first". -- Keep description under ~500 chars unless steering a multi-step flow. - -## TypeScript gotcha +## Architecture -`McpServer.registerTool` has deeply nested generic inference (`OutputArgs`/`InputArgs` union with `AnySchema`). Calling it inside a `for (const tool of tools)` loop triggers `TS2589: Type instantiation is excessively deep`. Workaround in `src/server.ts`: cast to a simpler function signature before invocation. Don't unwind into per-file registrations - the loop is fine, the cast is the right fix. +```text +MCP tools/list + -> GET /api/prism/v1/llm/tools/list -## Lint + format - Biome - -Single tool replaces ESLint + Prettier. Config in `biome.json`. - -- 2-space indent, 100-char line width -- Double quotes, semicolons, trailing commas -- Imports auto-organized on `npm run fix` -- `useImportType` (warn) - prefer `import type` for types -- `useNodejsImportProtocol` (warn) - `node:fs` not `fs` -- `noNonNullAssertion` (warn) - prefer `if (x)` narrowing over `x!` -- `noExplicitAny` (warn) +MCP tools/call + -> POST /api/prism/v1/llm/tools/call +``` -Commands: -- `npm run lint` - check only (CI uses this) -- `npm run fix` - apply all safe auto-fixes (imports, formatting, lint rules) -- `npm run format` - formatting only +Registry responses are validated, cached per Parseable URL, tenant, and +credential scope, then revalidated with `ETag`. Invocation results must be a +valid MCP `CallToolResult` and are never cached. -Biome runs in ~10ms across the whole repo. +Key files: -## Testing +```text +src/bootstrap.ts MCP tools/list and tools/call handlers +src/client.ts Parseable registry and invocation HTTP client +src/remote-tools.ts registry validation, LRU/TTL cache, ETag state +src/http.ts stateless Streamable HTTP transport and request auth +src/stdio.ts stdio transport +src/cloud.ts Parseable Cloud API-key routing +``` -Vitest + native `fetch` mocking. Tests live in `test/*.test.ts`. Coverage via `npm run test:coverage` (v8 provider). Excluded: `src/server.ts` (boot wiring), `src/tools/index.ts` (registry - covered by `registry.test.ts`). +## Adding or changing tools -Current: **95% statements / 98% functions / 95% lines / 108 tests**. +Tool changes belong in the Parseable server catalog and executor. This adapter +should change only when the REST or MCP contract changes. -## CI +Required invariants: -`.github/workflows/ci.yml` runs on push + PR to main: `npm ci` → `npm run lint` → `npm run build:all` → `npm test`. Node 22. +- Forward caller authentication and tenant routing on every upstream request. +- Reject malformed registries, invalid schemas, and duplicate tool names. +- Never cache tool invocation results. +- Scope registry cache entries so credentials cannot share private catalogs. +- Forward structured Parseable tool failures as MCP tool results. +- Convert malformed upstream results and transport failures into MCP errors. +- Do not log API keys, tool arguments, or tool results. -## Deliberately excluded - won't be accepted +## Verification -- `ingest_event` - wrong shape for this protocol -- `delete_*` - destructive, UI only -- `create_user` / `create_role` / RBAC mutation - UI/CLI only -- Live tail / streaming subscriptions - stdio transport not the right fit; use Parseable UI +```bash +npm run lint +npm run build:all +npm test +``` -## Releasing +Tests use Vitest, native `fetch` mocks, and linked in-memory MCP transports. +Add coverage for both REST mapping and complete MCP discovery/invocation flows. -Publishing a GitHub Release triggers `.github/workflows/release.yml`. The workflow verifies that the release tag matches the version in `package.json`, runs lint + build + test, then publishes the package to npm through trusted publishing with automatic provenance. +## Style and release -Before the first release, configure a GitHub Actions trusted publisher in the package settings on npmjs.com. Use organization `parseablehq`, repository `parseable-mcp-server`, workflow `release.yml`, and allow `npm publish`. No npm token is required. Create releases with a `v`-prefixed tag matching `package.json` (for example, package version `0.3.0` requires tag `v0.3.0`). +Biome owns formatting and linting (`npm run fix`). GitHub releases trigger npm +publishing; release tag and `package.json` version must match. diff --git a/README.md b/README.md index 8764ea7..74d697a 100644 --- a/README.md +++ b/README.md @@ -145,8 +145,6 @@ For self-hosted mode, HTTP server validates supplied URL and forwards API key to |-----|----------|---------|---------| | `PARSEABLE_URL` | ✅ | — | Parseable base URL | | `PARSEABLE_API_KEY` | ✅ | — | API key for self-hosted Parseable | -| `PARSEABLE_DEFAULT_DATASET` | | — | Advisory default dataset | -| `PARSEABLE_MAX_ROWS` | | 1000 | Hard cap on query rows | | `PARSEABLE_QUERY_TIMEOUT_MS` | | 30000 | HTTP timeout (ms) | ### HTTP mode @@ -178,67 +176,15 @@ Copy `.env.example` → `.env` for a full template. ## Tools -### Discovery +Tools are owned by the connected Parseable instance, not this adapter. For +MCP `tools/list`, the adapter fetches the edition-specific catalog from +`GET /api/prism/v1/llm/tools/list`. OSS, Enterprise, and Cloud can expose +different tool sets. -| Tool | Purpose | -|------|---------| -| `list_datasets` | List all log datasets | -| `get_dataset_schema` | Column names + types | -| `get_dataset_info` | Metadata (created_at, retention, time window) | -| `get_dataset_stats` | Event count and storage bytes | -| `sample_events` | Most recent N events (time-bounded, row-capped) | - -### Query - -| Tool | Purpose | -|------|---------| -| `query_sql` | SQL `SELECT` over a time window. DDL/DML blocked. Auto-injects `LIMIT`. | -| `query_promql` | PromQL instant or range query against a metrics dataset | - -### Alerts - -| Tool | Purpose | -|------|---------| -| `list_alerts` | List all alerts with state, severity, tags | -| `get_alert` | Full config for one alert | -| `list_alert_tags` | All alert tags in use | -| `enable_alert` | Enable an alert | -| `disable_alert` | Disable an alert | -| `evaluate_alert` | Force-evaluate now. **May fire real notifications.** | -| `create_alert` | Create alert via guided Q&A (8 questions, confirms before submit) | - -### Alert targets - -| Tool | Purpose | -|------|---------| -| `list_alert_targets` | List targets (Slack, webhook, Alertmanager) | -| `get_alert_target` | Full config for one target | -| `create_alert_target` | Create a new Slack / webhook / Alertmanager target | - -### Diagnostics - -| Tool | Purpose | -|------|---------| -| `ping` | Check connectivity, return version + health | -| `explain_query` | `EXPLAIN` a SQL query without executing it | - -### RBAC (read-only) - -| Tool | Purpose | -|------|---------| -| `list_users` | List all users | -| `get_user_roles` | Roles for a specific user | -| `list_roles` | All role names | -| `get_role` | Privilege definition for a role | -| `get_default_role` | Default role for new users | - -### Admin (read-only) - -| Tool | Purpose | -|------|---------| -| `get_cluster_status` | All nodes with status (distributed mode) | -| `get_cluster_metrics` | Aggregated ingest/query/storage metrics | -| `get_retention` | Retention policy for a dataset | +The adapter caches the catalog for its server-provided `max-age` and +revalidates it with `ETag`. MCP `tools/call` requests are forwarded to +`POST /api/prism/v1/llm/tools/call`, where Parseable validates arguments, +applies RBAC, executes the tool, and returns an MCP `CallToolResult`. --- diff --git a/package-lock.json b/package-lock.json index 68ba340..aae5875 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@parseable/parseable-mcp-server", - "version": "0.2.11", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@parseable/parseable-mcp-server", - "version": "0.2.11", + "version": "0.3.0", "license": "Apache-2.0", "dependencies": { "@hono/node-server": "^2.0.12", diff --git a/package.json b/package.json index fb4d8d0..67f4ff2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@parseable/parseable-mcp-server", - "version": "0.2.16", + "version": "0.3.0", "mcpName": "io.github.parseablehq/parseable-mcp-server", "description": "Model Context Protocol server for Parseable. Lets LLMs discover and query Parseable datasets.", "repository": { @@ -18,9 +18,11 @@ "LICENSE" ], "scripts": { - "build": "tsc && chmod +x dist/server.js", + "clean": "node scripts/clean.mjs", + "build": "npm run clean && npm run build:server", + "build:server": "tsc && chmod +x dist/server.js", "build:ui": "vite build", - "build:all": "npm run build:ui && npm run build", + "build:all": "npm run clean && npm run build:ui && npm run build:server", "dev": "tsc --watch", "dev:ui": "vite", "start": "node dist/server.js", @@ -30,7 +32,7 @@ "lint": "biome check src test", "format": "biome format --write src test", "fix": "biome check --write src test", - "prepublishOnly": "npm run build && npm run lint && npm test" + "prepublishOnly": "npm run build:all && npm run lint && npm test" }, "dependencies": { "@hono/node-server": "^2.0.12", diff --git a/scripts/clean.mjs b/scripts/clean.mjs new file mode 100644 index 0000000..e6f9b19 --- /dev/null +++ b/scripts/clean.mjs @@ -0,0 +1,4 @@ +import { rmSync } from "node:fs"; +import { resolve } from "node:path"; + +rmSync(resolve("dist"), { recursive: true, force: true }); diff --git a/src/bootstrap.ts b/src/bootstrap.ts index 9bb17c6..5e75883 100644 --- a/src/bootstrap.ts +++ b/src/bootstrap.ts @@ -1,59 +1,60 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { ParseableError } from "./client.js"; +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { + CallToolRequestSchema, + type CallToolResult, + CallToolResultSchema, + ListToolsRequestSchema, +} from "@modelcontextprotocol/sdk/types.js"; +import type { ParseableClient } from "./client.js"; import { PARSEABLE_ICON_DATA_URI } from "./icon.js"; -import { tools } from "./tools/index.js"; -import type { ToolContext } from "./tools/types.js"; -import { errorResult, jsonResult } from "./tools/types.js"; +import { getRemoteTools } from "./remote-tools.js"; const SERVER_VERSION = "0.2.16"; -export function buildMcpServer(ctx: ToolContext): McpServer { - const server = new McpServer({ - name: "parseable-mcp-server", - title: "Parseable", - version: SERVER_VERSION, - description: - "Talk to Parseable from your AI client. Query logs (SQL + PromQL), manage alerts, audit RBAC.", - websiteUrl: "https://www.parseable.com", - icons: [ - { - src: PARSEABLE_ICON_DATA_URI, - mimeType: "image/svg+xml", - sizes: ["any"], - }, - ], - }); +function toolError(message: string): CallToolResult { + return { isError: true, content: [{ type: "text", text: message }] }; +} - for (const tool of tools) { - const handler = async (args: Record) => { - try { - const data = await tool.handler(args, ctx); - return jsonResult(data); - } catch (err) { - if (err instanceof ParseableError) { - return errorResult(`${err.message}\n\nResponse body:\n${err.body}`); - } - return errorResult(err instanceof Error ? err.message : String(err)); - } - }; +export function buildMcpServer(client: ParseableClient): Server { + const server = new Server( + { + name: "parseable-mcp-server", + title: "Parseable", + version: SERVER_VERSION, + description: + "Talk to Parseable from your AI client. Query logs (SQL + PromQL), manage alerts, audit RBAC.", + websiteUrl: "https://www.parseable.com", + icons: [ + { + src: PARSEABLE_ICON_DATA_URI, + mimeType: "image/svg+xml", + sizes: ["any"], + }, + ], + }, + { capabilities: { tools: { listChanged: false } } }, + ); - // Cast avoids deep generic instantiation across the dynamic tool list. - ( - server.registerTool as unknown as ( - n: string, - c: Record, - cb: typeof handler, - ) => unknown - )( - tool.name, - { - title: tool.title, - description: tool.description, - inputSchema: tool.inputSchema, - }, - handler, - ); - } + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: await getRemoteTools(client), + })); + + server.setRequestHandler(CallToolRequestSchema, async (request) => { + try { + const result = await client.callTool(request.params.name, request.params.arguments ?? {}); + if ( + !result || + typeof result !== "object" || + !("content" in result) || + !Array.isArray(result.content) + ) { + throw new Error("Parseable tool invocation returned an invalid CallToolResult."); + } + return CallToolResultSchema.parse(result); + } catch (error) { + return toolError(error instanceof Error ? error.message : String(error)); + } + }); return server; } diff --git a/src/client.ts b/src/client.ts index 5132afe..f2b0d96 100644 --- a/src/client.ts +++ b/src/client.ts @@ -15,272 +15,137 @@ export class ParseableError extends Error { export function parseErrorBody(body: string): string { if (!body) return ""; try { - const j = JSON.parse(body); - if (typeof j === "string") return j; - if (j && typeof j === "object") { - const o = j as Record; - const msg = o.message ?? o.error ?? o.detail ?? o.reason; - if (typeof msg === "string" && msg.length > 0) return msg; + const parsed = JSON.parse(body); + if (typeof parsed === "string") return parsed; + if (parsed && typeof parsed === "object") { + const value = parsed as Record; + const message = value.message ?? value.error ?? value.detail ?? value.reason; + if (typeof message === "string" && message.length > 0) return message; } } catch { - // not JSON, fall through + // Plain-text error body. } return body.slice(0, 500); } export function classifyStatus(status: number, _method: string, path: string): string | undefined { - if (status === 401) { - return "Authentication failed. Check PARSEABLE_API_KEY."; - } + if (status === 401) return "Authentication failed. Check PARSEABLE_API_KEY."; if (status === 403) { return "Authorized but not permitted. The user lacks the required RBAC action for this endpoint."; } if (status === 404) { if (path.startsWith("/cluster/")) { - return "Cluster endpoints only exist on distributed Parseable deployments (query/coordinator mode). Standalone instances do not have /cluster/* routes."; - } - if (path.startsWith("/prometheus/")) { - return "PromQL endpoints may require a Parseable build with the prometheus feature enabled. Verify your server version."; + return "Cluster endpoints only exist on distributed Parseable deployments (query/coordinator mode)."; } - return "Resource not found. Check the dataset name, alert ID, or other identifier in the path."; - } - if (status === 408 || status === 504) { - return "Server timed out. Try a shorter time window or smaller query."; - } - if (status === 429) { - return "Rate limited. Parseable defaults to 100 req/min/IP - back off and retry."; - } - if (status >= 500) { - return "Parseable returned a server error. Check Parseable logs; this is likely not a config issue on the MCP side."; + return "Resource not found. Check the tool name or referenced resource."; } + if (status === 408 || status === 504) return "Server timed out. Try again."; + if (status === 429) return "Rate limited. Back off and retry."; + if (status >= 500) return "Parseable returned a server error. Check Parseable logs."; return undefined; } export class ParseableClient { - private apiKey: string; - private url: string; - private queryTimeoutMs: number; - private tenantId?: string; - public readonly maxRows: number; - private onUnauthorized?: () => void; + public readonly apiKey: string; + public readonly baseUrl: string; + public readonly tenantId?: string; + private readonly queryTimeoutMs: number; + private readonly onUnauthorized?: () => void; constructor(opts: Config, hooks: { onUnauthorized?: () => void } = {}) { - this.url = opts.url.replace(/\/+$/, ""); + this.baseUrl = opts.url.replace(/\/+$/, ""); this.queryTimeoutMs = opts.queryTimeoutMs; - this.maxRows = opts.maxRows; this.apiKey = opts.apiKey; this.tenantId = opts.tenantId; this.onUnauthorized = hooks.onUnauthorized; } - private async request( - method: string, - path: string, - body?: unknown, - opts: { basePath?: string; query?: Record } = {}, - ): Promise { - const base = opts.basePath ?? "/api/v1"; - const qs = opts.query ? `?${new URLSearchParams(opts.query).toString()}` : ""; - const url = `${this.url}${base}${path}${qs}`; - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), this.queryTimeoutMs); + private headers(): Record { + return { + "X-API-Key": this.apiKey, + ...(this.tenantId ? { "x-p-tenant": this.tenantId } : {}), + "Content-Type": "application/json", + Accept: "application/json", + }; + } + private async fetch(path: string, init: RequestInit): Promise { try { - const res = await fetch(url, { - method, - headers: { - "X-API-Key": this.apiKey, - ...(this.tenantId ? { "x-p-tenant": this.tenantId } : {}), - "Content-Type": "application/json", - Accept: "application/json", - }, - body: body !== undefined ? JSON.stringify(body) : undefined, - signal: controller.signal, + return await fetch(`${this.baseUrl}/api/prism/v1${path}`, { + ...init, + signal: AbortSignal.timeout(this.queryTimeoutMs), }); - - const text = await res.text(); - if (!res.ok) { - if (res.status === 401) this.onUnauthorized?.(); - const parsed = parseErrorBody(text); - const hint = classifyStatus(res.status, method, path); - const head = `Parseable ${method} ${path} → ${res.status} ${res.statusText}`; - const detail = parsed ? `\n${parsed}` : ""; - const hintLine = hint ? `\nHint: ${hint}` : ""; - throw new ParseableError(res.status, text, `${head}${detail}${hintLine}`, hint); - } - if (!text) return undefined as T; - try { - return JSON.parse(text) as T; - } catch { - return text as unknown as T; - } - } catch (err) { - if (err instanceof ParseableError) throw err; - if ((err as Error).name === "AbortError") { - throw new Error( - `Parseable request timed out after ${this.queryTimeoutMs}ms: ${method} ${path}. Try a shorter time window or raise PARSEABLE_QUERY_TIMEOUT_MS.`, - ); + } catch (error) { + const cause = error as Error & { code?: string; cause?: { code?: string } }; + if (cause.name === "TimeoutError" || cause.name === "AbortError") { + throw new Error(`Parseable request timed out after ${this.queryTimeoutMs}ms.`); } - const e = err as NodeJS.ErrnoException & { cause?: { code?: string } }; - const code = e.code ?? e.cause?.code; + const code = cause.code ?? cause.cause?.code; if (code === "ECONNREFUSED") { - throw new Error( - `Connection refused to ${this.url}. Is Parseable running and reachable from this machine?`, - ); + throw new Error(`Connection refused to ${this.baseUrl}. Is Parseable reachable?`); } if (code === "ENOTFOUND") { - throw new Error(`DNS lookup failed for ${this.url}. Check PARSEABLE_URL.`); + throw new Error(`DNS lookup failed for ${this.baseUrl}. Check PARSEABLE_URL.`); } - if (code === "CERT_HAS_EXPIRED" || code === "UNABLE_TO_VERIFY_LEAF_SIGNATURE") { - throw new Error( - `TLS certificate problem talking to ${this.url}: ${code}. Use a trusted cert or http:// for local testing.`, - ); - } - throw err; - } finally { - clearTimeout(timeout); + throw error; } } - listDatasets() { - return this.request("GET", "/logstream"); - } - - getDatasetSchema(name: string) { - return this.request("GET", `/logstream/${encodeURIComponent(name)}/schema`); - } - - getDatasetInfo(name: string) { - return this.request("GET", `/logstream/${encodeURIComponent(name)}/info`); - } - - getDatasetStats(name: string) { - return this.request("GET", `/logstream/${encodeURIComponent(name)}/stats`); - } - - query(payload: { query: string; startTime: string; endTime: string; sendNull?: boolean }) { - return this.request("POST", "/query", payload); - } - - promqlInstant(params: { - query: string; - stream: string; - time?: string; - timeout?: string; - limit?: string; - timestamp_format?: string; - }) { - return this.request("GET", "/query", undefined, { - basePath: "/prometheus/api/v1", - query: this.stringMap(params), + async fetchToolRegistry( + etag?: string, + ): Promise< + | { notModified: true; maxAgeMs: number } + | { notModified: false; body: unknown; etag?: string; maxAgeMs: number } + > { + const { parseCacheMaxAge } = await import("./remote-tools.js"); + const response = await this.fetch("/llm/tools/list", { + headers: { ...this.headers(), ...(etag ? { "If-None-Match": etag } : {}) }, }); - } + const maxAgeMs = parseCacheMaxAge(response.headers.get("cache-control")); + if (response.status === 304) return { notModified: true, maxAgeMs }; - promqlRange(params: { - query: string; - stream: string; - start: string; - end: string; - step?: string; - timeout?: string; - limit?: string; - timestamp_format?: string; - }) { - return this.request("GET", "/query_range", undefined, { - basePath: "/prometheus/api/v1", - query: this.stringMap(params), + const text = await response.text(); + if (!response.ok) { + if (response.status === 401) this.onUnauthorized?.(); + throw this.responseError(response, "GET", "/llm/tools/list", text); + } + let body: unknown; + try { + body = JSON.parse(text); + } catch { + throw new ParseableError(502, text, "Parseable tool registry returned invalid JSON."); + } + return { + notModified: false, + body, + etag: response.headers.get("etag") ?? undefined, + maxAgeMs, + }; + } + + async callTool(name: string, args: Record): Promise { + const response = await this.fetch("/llm/tools/call", { + method: "POST", + headers: this.headers(), + body: JSON.stringify({ name, arguments: args }), }); + const text = await response.text(); + if (response.status === 401) this.onUnauthorized?.(); + try { + return JSON.parse(text); + } catch { + throw this.responseError(response, "POST", "/llm/tools/call", text); + } } - listAlerts() { - return this.request("GET", "/alerts"); - } - - listAlertTags() { - return this.request("GET", "/alerts/list_tags"); - } - - getAlert(id: string) { - return this.request("GET", `/alerts/${encodeURIComponent(id)}`); - } - - createAlert(spec: unknown) { - return this.request("POST", "/alerts", spec); - } - - enableAlert(id: string) { - return this.request("PATCH", `/alerts/${encodeURIComponent(id)}/enable`); - } - - disableAlert(id: string) { - return this.request("PATCH", `/alerts/${encodeURIComponent(id)}/disable`); - } - - evaluateAlert(id: string) { - return this.request("PUT", `/alerts/${encodeURIComponent(id)}/evaluate_alert`); - } - - listTargets() { - return this.request("GET", "/targets"); - } - - getTarget(id: string) { - return this.request("GET", `/targets/${encodeURIComponent(id)}`); - } - - createTarget(spec: unknown) { - return this.request("POST", "/targets", spec); - } - - about() { - return this.request("GET", "/about"); - } - - liveness() { - return this.request("GET", "/liveness"); - } - - readiness() { - return this.request("GET", "/readiness"); - } - - listUsers() { - return this.request("GET", "/user"); - } - - getUserRoles(userid: string) { - return this.request("GET", `/user/${encodeURIComponent(userid)}/role`); - } - - listRoles() { - return this.request("GET", "/roles"); - } - - getRole(name: string) { - return this.request("GET", `/role/${encodeURIComponent(name)}`); - } - - getDefaultRole() { - return this.request("GET", "/role/default"); - } - - getClusterInfo() { - return this.request("GET", "/cluster/info"); - } - - getClusterMetrics() { - return this.request("GET", "/cluster/metrics"); - } - - getRetention(dataset: string) { - return this.request("GET", `/logstream/${encodeURIComponent(dataset)}/retention`); - } - - private stringMap(o: Record): Record { - const out: Record = {}; - for (const [k, v] of Object.entries(o)) { - if (v !== undefined && v !== null) out[k] = String(v); - } - return out; + private responseError(response: Response, method: string, path: string, body: string) { + const parsed = parseErrorBody(body); + const hint = classifyStatus(response.status, method, path); + return new ParseableError( + response.status, + body, + `Parseable ${method} ${path} → ${response.status} ${response.statusText}${parsed ? `\n${parsed}` : ""}${hint ? `\nHint: ${hint}` : ""}`, + hint, + ); } } diff --git a/src/config.ts b/src/config.ts index 91f7426..48e886e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -5,8 +5,6 @@ export interface Config { apiKey: string; tenantId?: string; mode?: "cloud" | "self-hosted"; - defaultDataset?: string; - maxRows: number; queryTimeoutMs: number; } @@ -20,8 +18,6 @@ export function loadConfig(): Config { return { url: required("PARSEABLE_URL").replace(/\/+$/, ""), apiKey: required("PARSEABLE_API_KEY"), - defaultDataset: process.env.PARSEABLE_DEFAULT_DATASET || undefined, - maxRows: Number(process.env.PARSEABLE_MAX_ROWS ?? 1000), queryTimeoutMs: Number(process.env.PARSEABLE_QUERY_TIMEOUT_MS ?? 30000), }; } diff --git a/src/http.ts b/src/http.ts index 5340e69..34bc5b0 100644 --- a/src/http.ts +++ b/src/http.ts @@ -6,15 +6,15 @@ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/ import { type Context, Hono } from "hono"; import { AuthError, assertNotPrivateUrl, parseRequestAuth } from "./auth.js"; import { buildMcpServer } from "./bootstrap.js"; -import { ParseableClient } from "./client.js"; +import { ParseableClient, ParseableError } from "./client.js"; import { evictCloudRouting, resolveCloudRouting } from "./cloud.js"; import type { Config } from "./config.js"; +import { getRemoteTools } from "./remote-tools.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const UI_DIR = join(__dirname, "ui"); const DEFAULT_PORT = 8787; -const DEFAULT_MAX_ROWS = 1000; const DEFAULT_QUERY_TIMEOUT_MS = 30_000; const HELP_TEXT = `Parseable MCP server @@ -115,7 +115,6 @@ async function buildApiKeyClient(reqHeaders: Headers): Promise<{ config: Config; }> { const auth = parseRequestAuth(reqHeaders); - const maxRows = Number(reqHeaders.get("X-Parseable-Max-Rows") ?? DEFAULT_MAX_ROWS); const queryTimeoutMs = Number( reqHeaders.get("X-Parseable-Query-Timeout-Ms") ?? DEFAULT_QUERY_TIMEOUT_MS, ); @@ -127,7 +126,6 @@ async function buildApiKeyClient(reqHeaders: Headers): Promise<{ tenantId: routing.tenantId, mode: "cloud", apiKey: auth.apiKey, - maxRows, queryTimeoutMs, }; return { @@ -143,7 +141,6 @@ async function buildApiKeyClient(reqHeaders: Headers): Promise<{ url: auth.url, apiKey: auth.apiKey, mode: "self-hosted", - maxRows, queryTimeoutMs, }; return { client: new ParseableClient(config), config }; @@ -152,8 +149,9 @@ async function buildApiKeyClient(reqHeaders: Headers): Promise<{ async function handleMcpPost(c: Context) { try { const built = await buildApiKeyClient(c.req.raw.headers); + await getRemoteTools(built.client); - const mcp = buildMcpServer({ client: built.client, config: built.config }); + const mcp = buildMcpServer(built.client); const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined, @@ -167,6 +165,12 @@ async function handleMcpPost(c: Context) { headers: { "Content-Type": "application/json" }, }); } + if (err instanceof ParseableError) { + return new Response(JSON.stringify({ error: err.message }), { + status: err.status, + headers: { "Content-Type": "application/json" }, + }); + } const message = err instanceof Error ? err.message : String(err); return c.json({ error: message }, 500); } diff --git a/src/remote-tools.ts b/src/remote-tools.ts new file mode 100644 index 0000000..a19f4e5 --- /dev/null +++ b/src/remote-tools.ts @@ -0,0 +1,139 @@ +import { createHash } from "node:crypto"; +import { LRUCache } from "lru-cache"; +import type { ParseableClient } from "./client.js"; + +export interface RemoteTool { + name: string; + title?: string; + description?: string; + inputSchema: { + type: "object"; + properties?: Record; + required?: string[]; + [key: string]: unknown; + }; + outputSchema?: { + type: "object"; + properties?: Record; + required?: string[]; + [key: string]: unknown; + }; + annotations?: { + title?: string; + readOnlyHint?: boolean; + destructiveHint?: boolean; + idempotentHint?: boolean; + openWorldHint?: boolean; + }; + icons?: Array<{ + src: string; + mimeType?: string; + sizes?: string[]; + theme?: "light" | "dark"; + }>; + execution?: { taskSupport?: "optional" | "required" | "forbidden" }; + _meta?: Record; +} + +interface CacheEntry { + tools: RemoteTool[]; + etag?: string; + freshUntil: number; +} + +const DEFAULT_TTL_MS = 5 * 60 * 1000; +const STALE_ENTRY_TTL_MS = 24 * 60 * 60 * 1000; +const registryCache = new LRUCache({ + max: 10_000, + ttl: STALE_ENTRY_TTL_MS, +}); +const inFlight = new Map>(); + +function cacheKey(client: ParseableClient): string { + const identity = `${client.baseUrl}\0${client.tenantId ?? ""}\0${client.apiKey}`; + return createHash("sha256").update(identity).digest("hex"); +} + +function validateTool(value: unknown): RemoteTool { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("Parseable tool registry contains a non-object tool."); + } + const tool = value as Record; + if (typeof tool.name !== "string" || tool.name.length === 0) { + throw new Error("Parseable tool registry contains a tool without a name."); + } + if ( + !tool.inputSchema || + typeof tool.inputSchema !== "object" || + Array.isArray(tool.inputSchema) || + (tool.inputSchema as Record).type !== "object" + ) { + throw new Error(`Parseable tool ${tool.name} has an invalid inputSchema.`); + } + return tool as unknown as RemoteTool; +} + +function validateRegistry(value: unknown): RemoteTool[] { + if (!value || typeof value !== "object" || !Array.isArray((value as { tools?: unknown }).tools)) { + throw new Error("Parseable tool registry response must contain a tools array."); + } + const tools = (value as { tools: unknown[] }).tools.map(validateTool); + const names = new Set(); + for (const tool of tools) { + if (names.has(tool.name)) { + throw new Error(`Parseable tool registry contains duplicate tool ${tool.name}.`); + } + names.add(tool.name); + } + return tools; +} + +export async function getRemoteTools(client: ParseableClient): Promise { + const key = cacheKey(client); + const cached = registryCache.get(key); + if (cached && cached.freshUntil > Date.now()) return cached.tools; + + const pending = inFlight.get(key); + if (pending) return pending; + + const lookup = client + .fetchToolRegistry(cached?.etag) + .then((response) => { + if (response.notModified) { + if (!cached) throw new Error("Parseable returned 304 without a cached tool registry."); + const refreshed = { + ...cached, + freshUntil: Date.now() + response.maxAgeMs, + }; + registryCache.set(key, refreshed); + return refreshed.tools; + } + const tools = validateRegistry(response.body); + registryCache.set(key, { + tools, + etag: response.etag, + freshUntil: Date.now() + response.maxAgeMs, + }); + return tools; + }) + .catch((error) => { + if (error && typeof error === "object" && "status" in error && error.status === 401) { + registryCache.delete(key); + } + throw error; + }) + .finally(() => inFlight.delete(key)); + + inFlight.set(key, lookup); + return lookup; +} + +export function parseCacheMaxAge(value: string | null): number { + const match = value?.match(/(?:^|,)\s*max-age=(\d+)/i); + return match ? Number(match[1]) * 1000 : DEFAULT_TTL_MS; +} + +export function clearToolRegistryCache(): void { + registryCache.clear(); + inFlight.clear(); +} diff --git a/src/stdio.ts b/src/stdio.ts index 2beb1dc..45ab93f 100644 --- a/src/stdio.ts +++ b/src/stdio.ts @@ -2,15 +2,16 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" import { buildMcpServer } from "./bootstrap.js"; import { ParseableClient } from "./client.js"; import { loadConfig } from "./config.js"; -import { tools } from "./tools/index.js"; +import { getRemoteTools } from "./remote-tools.js"; export async function startStdio(): Promise { const config = loadConfig(); const client = new ParseableClient(config); - const server = buildMcpServer({ client, config }); + const discoveredTools = await getRemoteTools(client); + const server = buildMcpServer(client); const transport = new StdioServerTransport(); await server.connect(transport); console.error( - `parseable-mcp-server connected. ${tools.length} tools registered. Target: ${config.url}`, + `parseable-mcp-server connected. ${discoveredTools.length} tools discovered. Target: ${config.url}`, ); } diff --git a/src/tools/create_alert.ts b/src/tools/create_alert.ts deleted file mode 100644 index 1e7ed3d..0000000 --- a/src/tools/create_alert.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { z } from "zod"; -import type { ToolDef } from "./types.js"; - -const schema = { - spec: z.record(z.unknown()).describe( - `Complete alert object sent as the POST /api/v1/alerts request body. Use camelCase JSON: -{ - "title": "High error count", - "alertType": "threshold", - "query": "SELECT ... FROM logs ...", - "severity": "high", - "thresholdConfig": { "operator": ">", "value": 10 }, - "evalConfig": { - "rollingWindow": { - "evalStart": "10 minutes", - "evalEnd": "now", - "evalFrequency": 10 - } - }, - "anomalyConfig": { "historicDuration": "1d" }, - "forecastConfig": { "historicDuration": "1d", "forecastDuration": "3h" }, - "notificationConfig": { "interval": 1 }, - "targets": ["target-id"], - "tags": [] -} - -Required by the Prism create-alert flow: title, alertType, query, severity, -thresholdConfig, evalConfig, anomalyConfig, forecastConfig, -notificationConfig, targets, and tags. thresholdConfig.value and both interval -fields are numbers. evalStart uses a duration such as "10 minutes"; evalEnd is -"now"; evalFrequency is minutes. targets must contain valid alert-target IDs. -For threshold alerts, use alertType "threshold". Prism currently shows anomaly -and forecast as unavailable creation modes; retain their default config objects. - -Reference: https://www.parseable.com/docs/user-guide/alerting`, - ), -}; - -export const createAlert: ToolDef = { - name: "create_alert", - title: "Create alert", - description: `Create a new Parseable alert. - -BEFORE calling this tool, gather inputs from the user ONE QUESTION AT A TIME. Do not invent values. Ask in this order: - -1. "What should this alert be called?" → title -2. "Which dataset should it watch?" → call list_datasets if user is unsure, then pick -3. "What condition should trigger it?" → translate user's natural-language condition into: - - alert SQL against the selected dataset, matching Parseable's alert-query format - - operator from > < = >= <= != - - numeric threshold value - Confirm the translated SQL + operator + value back to the user. -4. "How far back should each check look?" → evalStart (e.g. "5 minutes", "15 minutes", "1 hour") -5. "How often should it evaluate?" → evalFrequency in minutes (integer) -6. "Severity: critical, high, medium, or low?" → default high -7. "Any tags? (comma-separated, optional)" → tags array, empty if none -8. "Notification targets?" → call list_alert_targets to fetch existing targets, present them as a numbered list, ask user to pick by number/name. Collect chosen target IDs into the targets array. Prism requires at least one target to create an alert. - -Assemble alertType "threshold", evalEnd "now", anomalyConfig -{ historicDuration: "1d" }, forecastConfig { historicDuration: "1d", -forecastDuration: "3h" }, and notificationConfig { interval: 1 } unless the user -requests supported alternatives. Then show the complete JSON spec and ask "Create -this alert?" Only call create_alert after explicit confirmation. If user wants -edits, update fields and re-confirm.`, - inputSchema: schema, - handler: async (args, { client }) => { - return await client.createAlert(args.spec); - }, -}; diff --git a/src/tools/create_alert_target.ts b/src/tools/create_alert_target.ts deleted file mode 100644 index 65ab905..0000000 --- a/src/tools/create_alert_target.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { z } from "zod"; -import type { ToolDef } from "./types.js"; - -const schema = { - spec: z.record(z.unknown()).describe( - `Target spec, camelCase JSON. Shape varies by type: - -Slack: -{ "name": "...", "type": "slack", "endpoint": "https://hooks.slack.com/..." } - -Generic webhook: -{ "name": "...", "type": "webhook", "endpoint": "https://...", "headers": {...}, "skipTlsCheck": false } - -Alertmanager: -{ "name": "...", "type": "alertManager", "endpoint": "https://...", "skipTlsCheck": false, "username": "...", "password": "..." } - -Optional on all: { "notificationConfig": { "interval": 60 } } - -Reference: https://www.parseable.com/docs/user-guide/alerting`, - ), -}; - -export const createAlertTarget: ToolDef = { - name: "create_alert_target", - title: "Create alert target", - description: - "Create a new notification target (Slack/webhook/Alertmanager). Ask the user step-by-step: name, type, endpoint URL, then type-specific fields (headers for webhook, basic auth for Alertmanager). Confirm full JSON before submitting.", - inputSchema: schema, - handler: async (args, { client }) => { - return await client.createTarget(args.spec); - }, -}; diff --git a/src/tools/disable_alert.ts b/src/tools/disable_alert.ts deleted file mode 100644 index 2c3fafd..0000000 --- a/src/tools/disable_alert.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { z } from "zod"; -import type { ToolDef } from "./types.js"; - -const schema = { - alert_id: z.string().min(1).describe("Alert ID (UUID) to disable."), -}; - -export const disableAlert: ToolDef = { - name: "disable_alert", - title: "Disable alert", - description: "Disable an active alert (stops evaluating + firing).", - inputSchema: schema, - handler: async (args, { client }) => { - return await client.disableAlert(String(args.alert_id)); - }, -}; diff --git a/src/tools/enable_alert.ts b/src/tools/enable_alert.ts deleted file mode 100644 index f7ddf8c..0000000 --- a/src/tools/enable_alert.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { z } from "zod"; -import type { ToolDef } from "./types.js"; - -const schema = { - alert_id: z.string().min(1).describe("Alert ID (UUID) to enable."), -}; - -export const enableAlert: ToolDef = { - name: "enable_alert", - title: "Enable alert", - description: "Enable a previously-disabled alert.", - inputSchema: schema, - handler: async (args, { client }) => { - return await client.enableAlert(String(args.alert_id)); - }, -}; diff --git a/src/tools/evaluate_alert.ts b/src/tools/evaluate_alert.ts deleted file mode 100644 index de3b952..0000000 --- a/src/tools/evaluate_alert.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { z } from "zod"; -import type { ToolDef } from "./types.js"; - -const schema = { - alert_id: z.string().min(1).describe("Alert ID (UUID) to evaluate now."), -}; - -export const evaluateAlert: ToolDef = { - name: "evaluate_alert", - title: "Evaluate alert now", - description: - "Force-evaluate an alert immediately (out-of-schedule). MAY TRIGGER REAL NOTIFICATIONS if threshold breached - confirm with user before running on production alerts.", - inputSchema: schema, - handler: async (args, { client }) => { - return await client.evaluateAlert(String(args.alert_id)); - }, -}; diff --git a/src/tools/explain_query.ts b/src/tools/explain_query.ts deleted file mode 100644 index 4d2afa3..0000000 --- a/src/tools/explain_query.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { z } from "zod"; -import type { ToolDef } from "./types.js"; - -const schema = { - query: z - .string() - .min(1) - .describe( - "SQL SELECT to explain (do not include leading EXPLAIN - added automatically). Same dialect as query_sql.", - ), - startTime: z - .string() - .min(1) - .describe("Start of time window. RFC3339 or relative (e.g. 1h, 24h)."), - endTime: z.string().min(1).describe('End of time window. RFC3339 or "now".'), -}; - -const FORBIDDEN = /\b(INSERT|UPDATE|DELETE|DROP|ALTER|CREATE|TRUNCATE|GRANT|REVOKE|COPY|MERGE)\b/i; - -export const explainQuery: ToolDef = { - name: "explain_query", - title: "Explain SQL query plan", - description: - "Run EXPLAIN on a SQL query to see the DataFusion execution plan without executing it. Useful for debugging slow queries, verifying predicate pushdown, and checking partition pruning. Same SELECT-only restriction as query_sql.", - inputSchema: schema, - handler: async (args, { client }) => { - const userSql = String(args.query).trim().replace(/;$/, ""); - if (FORBIDDEN.test(userSql)) { - throw new Error("Only SELECT queries can be explained. DDL/DML keywords detected."); - } - if (/^\s*EXPLAIN\b/i.test(userSql)) { - throw new Error("Do not prefix the query with EXPLAIN - this tool adds it automatically."); - } - - const query = `EXPLAIN ${userSql}`; - return await client.query({ - query, - startTime: String(args.startTime), - endTime: String(args.endTime), - }); - }, -}; diff --git a/src/tools/get_alert.ts b/src/tools/get_alert.ts deleted file mode 100644 index 90b50b0..0000000 --- a/src/tools/get_alert.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { z } from "zod"; -import type { ToolDef } from "./types.js"; - -const schema = { - alert_id: z.string().min(1).describe("Alert ID (UUID). Use list_alerts to discover."), -}; - -export const getAlert: ToolDef = { - name: "get_alert", - title: "Get alert", - description: - "Get full configuration for one alert: query, threshold, evaluation window, targets, tags, state, last fired timestamp.", - inputSchema: schema, - handler: async (args, { client }) => { - return await client.getAlert(String(args.alert_id)); - }, -}; diff --git a/src/tools/get_alert_target.ts b/src/tools/get_alert_target.ts deleted file mode 100644 index 4a7c5e6..0000000 --- a/src/tools/get_alert_target.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { z } from "zod"; -import type { ToolDef } from "./types.js"; - -const schema = { - target_id: z.string().min(1).describe("Target ID (ULID). Use list_alert_targets to discover."), -}; - -export const getAlertTarget: ToolDef = { - name: "get_alert_target", - title: "Get alert target", - description: - "Get full config for one alert target (endpoint URL, auth, headers, notification interval).", - inputSchema: schema, - handler: async (args, { client }) => { - return await client.getTarget(String(args.target_id)); - }, -}; diff --git a/src/tools/get_cluster_metrics.ts b/src/tools/get_cluster_metrics.ts deleted file mode 100644 index ea3a56a..0000000 --- a/src/tools/get_cluster_metrics.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { ToolDef } from "./types.js"; - -export const getClusterMetrics: ToolDef = { - name: "get_cluster_metrics", - title: "Get cluster metrics", - description: - "Get aggregated metrics across all nodes in this Parseable cluster (ingest rate, query latency, storage utilization, errors). Distributed mode only.", - inputSchema: {}, - handler: async (_args, { client }) => { - return await client.getClusterMetrics(); - }, -}; diff --git a/src/tools/get_cluster_status.ts b/src/tools/get_cluster_status.ts deleted file mode 100644 index d4a1f8f..0000000 --- a/src/tools/get_cluster_status.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { ToolDef } from "./types.js"; - -export const getClusterStatus: ToolDef = { - name: "get_cluster_status", - title: "Get cluster status", - description: - "List all nodes in this Parseable cluster (Prism, Querier, Ingestor, Indexer) with their domains, status, and metadata. Only works on distributed Parseable deployments - standalone instances will return an error.", - inputSchema: {}, - handler: async (_args, { client }) => { - return await client.getClusterInfo(); - }, -}; diff --git a/src/tools/get_dataset_info.ts b/src/tools/get_dataset_info.ts deleted file mode 100644 index 101a937..0000000 --- a/src/tools/get_dataset_info.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { z } from "zod"; -import type { ToolDef } from "./types.js"; - -const schema = { - dataset: z.string().min(1).describe("Dataset (log stream) name."), -}; - -export const getDatasetInfo: ToolDef = { - name: "get_dataset_info", - title: "Get dataset info", - description: - "Get metadata for a dataset: created_at, first/last event time, owner, retention, hot-tier config, time partition.", - inputSchema: schema, - handler: async (args, { client }) => { - const dataset = args.dataset as string; - return await client.getDatasetInfo(dataset); - }, -}; diff --git a/src/tools/get_dataset_schema.ts b/src/tools/get_dataset_schema.ts deleted file mode 100644 index 08d0c85..0000000 --- a/src/tools/get_dataset_schema.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { z } from "zod"; -import type { ToolDef } from "./types.js"; - -const schema = { - dataset: z.string().min(1).describe("Dataset (log stream) name. Use list_datasets to discover."), -}; - -export const getDatasetSchema: ToolDef = { - name: "get_dataset_schema", - title: "Get dataset schema", - description: - "Get the Arrow schema (columns + types) for a dataset. Run this before writing SQL so you know available columns.", - inputSchema: schema, - handler: async (args, { client }) => { - const dataset = args.dataset as string; - return await client.getDatasetSchema(dataset); - }, -}; diff --git a/src/tools/get_dataset_stats.ts b/src/tools/get_dataset_stats.ts deleted file mode 100644 index 28d5513..0000000 --- a/src/tools/get_dataset_stats.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { z } from "zod"; -import type { ToolDef } from "./types.js"; - -const schema = { - dataset: z.string().min(1).describe("Dataset (log stream) name."), -}; - -export const getDatasetStats: ToolDef = { - name: "get_dataset_stats", - title: "Get dataset stats", - description: - "Get event count, ingested bytes, and storage (compressed) bytes for a dataset. Useful for cost and volume audits.", - inputSchema: schema, - handler: async (args, { client }) => { - const dataset = args.dataset as string; - return await client.getDatasetStats(dataset); - }, -}; diff --git a/src/tools/get_default_role.ts b/src/tools/get_default_role.ts deleted file mode 100644 index 38fada3..0000000 --- a/src/tools/get_default_role.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { ToolDef } from "./types.js"; - -export const getDefaultRole: ToolDef = { - name: "get_default_role", - title: "Get default role", - description: - "Get the default role assigned to new users on this Parseable server. Returns the role name, or null if no default is set.", - inputSchema: {}, - handler: async (_args, { client }) => { - return await client.getDefaultRole(); - }, -}; diff --git a/src/tools/get_retention.ts b/src/tools/get_retention.ts deleted file mode 100644 index cdb6d3c..0000000 --- a/src/tools/get_retention.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { z } from "zod"; -import type { ToolDef } from "./types.js"; - -const schema = { - dataset: z.string().min(1).describe("Dataset (log stream) name. Use list_datasets to discover."), -}; - -export const getRetention: ToolDef = { - name: "get_retention", - title: "Get dataset retention policy", - description: - "Get the retention policy for a dataset - how long data is kept before being deleted, and any tiering rules. Read-only; modification stays in the Parseable UI.", - inputSchema: schema, - handler: async (args, { client }) => { - return await client.getRetention(String(args.dataset)); - }, -}; diff --git a/src/tools/get_role.ts b/src/tools/get_role.ts deleted file mode 100644 index a984e50..0000000 --- a/src/tools/get_role.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { z } from "zod"; -import type { ToolDef } from "./types.js"; - -const schema = { - name: z.string().min(1).describe("Role name. Use list_roles to discover."), -}; - -export const getRole: ToolDef = { - name: "get_role", - title: "Get role", - description: - "Get the full privilege definition for a role: which actions (Ingest, Query, GetStream, PutAlert, etc.) the role grants, and which datasets each privilege applies to. Use to answer 'does role X let me write to dataset Y?' style questions.", - inputSchema: schema, - handler: async (args, { client }) => { - return await client.getRole(String(args.name)); - }, -}; diff --git a/src/tools/get_user_roles.ts b/src/tools/get_user_roles.ts deleted file mode 100644 index ba9e96b..0000000 --- a/src/tools/get_user_roles.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { z } from "zod"; -import type { ToolDef } from "./types.js"; - -const schema = { - userid: z.string().min(1).describe("User ID (username). Use list_users to discover."), -}; - -export const getUserRoles: ToolDef = { - name: "get_user_roles", - title: "Get user roles", - description: - "Get the roles assigned to a specific user. Returns a map of role name → role definition. Combine with list_roles + get_role to determine effective permissions for a user (which datasets they can read, write, manage alerts on, etc.).", - inputSchema: schema, - handler: async (args, { client }) => { - return await client.getUserRoles(String(args.userid)); - }, -}; diff --git a/src/tools/index.ts b/src/tools/index.ts deleted file mode 100644 index 87a85fd..0000000 --- a/src/tools/index.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { createAlert } from "./create_alert.js"; -import { createAlertTarget } from "./create_alert_target.js"; -import { disableAlert } from "./disable_alert.js"; -import { enableAlert } from "./enable_alert.js"; -import { evaluateAlert } from "./evaluate_alert.js"; -import { explainQuery } from "./explain_query.js"; -import { getAlert } from "./get_alert.js"; -import { getAlertTarget } from "./get_alert_target.js"; -import { getClusterMetrics } from "./get_cluster_metrics.js"; -import { getClusterStatus } from "./get_cluster_status.js"; -import { getDatasetInfo } from "./get_dataset_info.js"; -import { getDatasetSchema } from "./get_dataset_schema.js"; -import { getDatasetStats } from "./get_dataset_stats.js"; -import { getDefaultRole } from "./get_default_role.js"; -import { getRetention } from "./get_retention.js"; -import { getRole } from "./get_role.js"; -import { getUserRoles } from "./get_user_roles.js"; -import { listAlertTags } from "./list_alert_tags.js"; -import { listAlertTargets } from "./list_alert_targets.js"; -import { listAlerts } from "./list_alerts.js"; -import { listDatasets } from "./list_datasets.js"; -import { listRoles } from "./list_roles.js"; -import { listUsers } from "./list_users.js"; -import { ping } from "./ping.js"; -import { queryPromql } from "./query_promql.js"; -import { querySql } from "./query_sql.js"; -import { sampleEvents } from "./sample_events.js"; -import type { ToolDef } from "./types.js"; - -export const tools: ToolDef[] = [ - listDatasets, - getDatasetSchema, - getDatasetInfo, - getDatasetStats, - sampleEvents, - querySql, - queryPromql, - listAlerts, - getAlert, - listAlertTags, - enableAlert, - disableAlert, - evaluateAlert, - createAlert, - listAlertTargets, - getAlertTarget, - createAlertTarget, - ping, - explainQuery, - listUsers, - getUserRoles, - listRoles, - getRole, - getDefaultRole, - getClusterStatus, - getClusterMetrics, - getRetention, -]; diff --git a/src/tools/list_alert_tags.ts b/src/tools/list_alert_tags.ts deleted file mode 100644 index e2a3faa..0000000 --- a/src/tools/list_alert_tags.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { ToolDef } from "./types.js"; - -export const listAlertTags: ToolDef = { - name: "list_alert_tags", - title: "List alert tags", - description: - "List all tags currently in use across alerts. Useful for filtering and grouping alerts by service/team/severity.", - inputSchema: {}, - handler: async (_args, { client }) => { - return await client.listAlertTags(); - }, -}; diff --git a/src/tools/list_alert_targets.ts b/src/tools/list_alert_targets.ts deleted file mode 100644 index a40aa4d..0000000 --- a/src/tools/list_alert_targets.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { ToolDef } from "./types.js"; - -export const listAlertTargets: ToolDef = { - name: "list_alert_targets", - title: "List alert targets", - description: - "List all alert notification targets (Slack webhooks, generic webhooks, Alertmanager endpoints) configured on this Parseable server. Returns array of `{id, name, type, ...}`. Use BEFORE create_alert so the user can pick valid target IDs for the alert's `targets` array.", - inputSchema: {}, - handler: async (_args, { client }) => { - return await client.listTargets(); - }, -}; diff --git a/src/tools/list_alerts.ts b/src/tools/list_alerts.ts deleted file mode 100644 index 1788d46..0000000 --- a/src/tools/list_alerts.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { ToolDef } from "./types.js"; - -export const listAlerts: ToolDef = { - name: "list_alerts", - title: "List alerts", - description: - "List all alerts on this Parseable server with their IDs, names, state (enabled/disabled), severity, and last evaluation. Use first to discover what alerts exist before drilling in with get_alert.", - inputSchema: {}, - handler: async (_args, { client }) => { - return await client.listAlerts(); - }, -}; diff --git a/src/tools/list_datasets.ts b/src/tools/list_datasets.ts deleted file mode 100644 index 7c6c5db..0000000 --- a/src/tools/list_datasets.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { ToolDef } from "./types.js"; - -export const listDatasets: ToolDef = { - name: "list_datasets", - title: "List datasets", - description: - "List all log datasets (streams) on this Parseable server. Returns an array of dataset metadata. Use this first to discover what data is available.", - inputSchema: {}, - handler: async (_args, { client }) => { - return await client.listDatasets(); - }, -}; diff --git a/src/tools/list_roles.ts b/src/tools/list_roles.ts deleted file mode 100644 index ebc69a6..0000000 --- a/src/tools/list_roles.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { ToolDef } from "./types.js"; - -export const listRoles: ToolDef = { - name: "list_roles", - title: "List roles", - description: - "List all roles defined on this Parseable server. Returns role names. Use get_role on each to inspect privileges. Read-only - this server does not provide role creation/modification.", - inputSchema: {}, - handler: async (_args, { client }) => { - return await client.listRoles(); - }, -}; diff --git a/src/tools/list_users.ts b/src/tools/list_users.ts deleted file mode 100644 index ebeaa2e..0000000 --- a/src/tools/list_users.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { ToolDef } from "./types.js"; - -export const listUsers: ToolDef = { - name: "list_users", - title: "List users", - description: - "List all registered users on this Parseable server. Returns user IDs and basic metadata. Caller must have ListUser permission. Read-only - this server does not provide user creation/deletion.", - inputSchema: {}, - handler: async (_args, { client }) => { - return await client.listUsers(); - }, -}; diff --git a/src/tools/ping.ts b/src/tools/ping.ts deleted file mode 100644 index f974703..0000000 --- a/src/tools/ping.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { ToolDef } from "./types.js"; - -export const ping: ToolDef = { - name: "ping", - title: "Ping Parseable server", - description: - "Check connectivity and report server version/build info. Returns about (version, mode, license, deployment id), liveness, and readiness. Use for diagnosing MCP-server-to-Parseable connection issues.", - inputSchema: {}, - handler: async (_args, { client, config }) => { - const result: Record = { target: config.url }; - - const settle = async (label: string, p: Promise) => { - try { - result[label] = await p; - } catch (err) { - result[label] = { - error: err instanceof Error ? err.message : String(err), - }; - } - }; - - await Promise.all([ - settle("about", client.about()), - settle("liveness", client.liveness()), - settle("readiness", client.readiness()), - ]); - - return result; - }, -}; diff --git a/src/tools/query_promql.ts b/src/tools/query_promql.ts deleted file mode 100644 index a9ac5b2..0000000 --- a/src/tools/query_promql.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { z } from "zod"; -import type { ToolDef } from "./types.js"; - -function toEpoch(v: unknown): string | undefined { - if (v === undefined || v === null || v === "") return undefined; - const s = String(v).trim(); - if (/^\d+(\.\d+)?$/.test(s)) return s; - const ms = Date.parse(s); - if (Number.isNaN(ms)) { - throw new Error( - `Invalid timestamp: ${s}. Use unix epoch seconds or RFC3339 (e.g. 2026-05-13T10:00:00Z).`, - ); - } - return String(Math.floor(ms / 1000)); -} - -const schema = { - query: z - .string() - .min(1) - .describe( - "PromQL expression, e.g. rate(http_requests_total[5m]) or sum by (status) (http_requests_total).", - ), - stream: z - .string() - .min(1) - .describe("Metrics dataset (log stream) name in Parseable, e.g. otel_metrics."), - start: z - .string() - .optional() - .describe( - "Range query start. Unix epoch seconds (e.g. 1747123200) or RFC3339 (auto-converted to epoch). If start+end provided → range query. Omit for instant query.", - ), - end: z - .string() - .optional() - .describe("Range query end. Unix epoch seconds or RFC3339 (auto-converted)."), - step: z - .string() - .optional() - .describe( - "Range query resolution, e.g. 60s, 5m, 1h. Defaults to Parseable default if omitted on range query.", - ), - time: z - .string() - .optional() - .describe( - "Instant query evaluation timestamp. Unix epoch seconds or RFC3339 (auto-converted). Defaults to now.", - ), - timeout: z.string().optional().describe("Query timeout seconds (default 120)."), - limit: z.string().optional().describe("Max data points to return."), - timestamp_format: z - .enum(["rfc3339", "unix"]) - .optional() - .describe("Output timestamp format. Default unix epoch."), -}; - -export const queryPromql: ToolDef = { - name: "query_promql", - title: "Run PromQL query", - description: - "Execute a PromQL query against a Parseable metrics dataset. Pass start+end+step for a range query (time series), or omit them for an instant query (single point). Requires a metrics dataset name in `stream`.", - inputSchema: schema, - handler: async (args, { client }) => { - const query = String(args.query); - const stream = String(args.stream); - const start = toEpoch(args.start); - const end = toEpoch(args.end); - const step = args.step ? String(args.step) : undefined; - const time = toEpoch(args.time); - const timeout = args.timeout ? String(args.timeout) : undefined; - const limit = args.limit ? String(args.limit) : undefined; - const timestamp_format = args.timestamp_format ? String(args.timestamp_format) : undefined; - - if (start && end) { - return await client.promqlRange({ - query, - stream, - start, - end, - step, - timeout, - limit, - timestamp_format, - }); - } - - return await client.promqlInstant({ - query, - stream, - time, - timeout, - limit, - timestamp_format, - }); - }, -}; diff --git a/src/tools/query_sql.ts b/src/tools/query_sql.ts deleted file mode 100644 index 39a2d20..0000000 --- a/src/tools/query_sql.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { z } from "zod"; -import type { ToolDef } from "./types.js"; - -const schema = { - query: z - .string() - .min(1) - .describe( - 'SQL SELECT query. Reference the dataset as a quoted table name, e.g. SELECT * FROM "my_dataset". DataFusion SQL dialect. SELECT only - DDL/DML rejected.', - ), - startTime: z - .string() - .min(1) - .describe( - "Start of time window. RFC3339 (e.g. 2026-05-13T10:00:00Z) or relative (e.g. 1h, 24h, 7d).", - ), - endTime: z - .string() - .min(1) - .describe('End of time window. RFC3339 or relative (e.g. now). Use "now" for current time.'), -}; - -const FORBIDDEN = /\b(INSERT|UPDATE|DELETE|DROP|ALTER|CREATE|TRUNCATE|GRANT|REVOKE|COPY|MERGE)\b/i; -const LIMIT_RE = /\blimit\s+\d+/i; - -export const querySql: ToolDef = { - name: "query_sql", - title: "Run SQL query", - description: - "Execute a SQL SELECT against a Parseable dataset over a time window. Use this for any analytical question on logs/events. Run get_dataset_schema first to know columns. Time window is mandatory.", - inputSchema: schema, - handler: async (args, { client, config }) => { - let query = String(args.query).trim(); - const startTime = String(args.startTime); - const endTime = String(args.endTime); - - if (FORBIDDEN.test(query)) { - throw new Error("Only SELECT queries are allowed. DDL/DML keywords detected."); - } - - const hadLimit = LIMIT_RE.test(query); - if (!hadLimit) { - query = `${query.replace(/;$/, "")} LIMIT ${config.maxRows}`; - } - - const result = (await client.query({ - query, - startTime, - endTime, - })) as unknown; - - const rows = Array.isArray(result) ? result : []; - const truncated = !hadLimit && rows.length >= config.maxRows; - - return { - rows, - row_count: rows.length, - truncated, - effective_query: query, - time_window: { startTime, endTime }, - }; - }, -}; diff --git a/src/tools/sample_events.ts b/src/tools/sample_events.ts deleted file mode 100644 index 4c5f03a..0000000 --- a/src/tools/sample_events.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { z } from "zod"; -import type { ToolDef } from "./types.js"; - -const schema = { - dataset: z.string().min(1).describe("Dataset (log stream) name."), - limit: z - .number() - .int() - .min(1) - .max(100) - .default(10) - .describe("Number of recent events to return (1-100, default 10)."), - minutes: z - .number() - .int() - .min(1) - .max(1440) - .default(60) - .describe("How many minutes back to look (1-1440, default 60)."), -}; - -export const sampleEvents: ToolDef = { - name: "sample_events", - title: "Sample recent events", - description: - "Return the most recent N events from a dataset. Useful for understanding the shape of data before writing SQL.", - inputSchema: schema, - handler: async (args, { client, config }) => { - const dataset = args.dataset as string; - const limit = Math.min(Number(args.limit ?? 10), Math.min(100, config.maxRows)); - const minutes = Number(args.minutes ?? 60); - - const endTime = new Date(); - const startTime = new Date(endTime.getTime() - minutes * 60_000); - - const safeName = dataset.replace(/"/g, '""'); - const query = `SELECT * FROM "${safeName}" ORDER BY p_timestamp DESC LIMIT ${limit}`; - - return await client.query({ - query, - startTime: startTime.toISOString(), - endTime: endTime.toISOString(), - }); - }, -}; diff --git a/src/tools/types.ts b/src/tools/types.ts deleted file mode 100644 index bcc84c5..0000000 --- a/src/tools/types.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { ZodRawShape } from "zod"; -import type { ParseableClient } from "../client.js"; -import type { Config } from "../config.js"; - -export interface ToolContext { - client: ParseableClient; - config: Config; -} - -export interface ToolDef { - name: string; - title: string; - description: string; - inputSchema: Args; - handler: (args: Record, ctx: ToolContext) => Promise; -} - -export function jsonResult(data: unknown) { - return { - content: [ - { - type: "text" as const, - text: JSON.stringify(data, null, 2), - }, - ], - }; -} - -export function errorResult(message: string) { - return { - isError: true, - content: [{ type: "text" as const, text: message }], - }; -} diff --git a/src/ui-app/components/landing/FeatureGrid.tsx b/src/ui-app/components/landing/FeatureGrid.tsx index fdf66e1..5269eb0 100644 --- a/src/ui-app/components/landing/FeatureGrid.tsx +++ b/src/ui-app/components/landing/FeatureGrid.tsx @@ -27,7 +27,7 @@ const FEATURES = [ iconColor: "#0891B2", iconBg: "rgba(8,145,178,0.08)", title: "Full tool surface", - desc: "Datasets, alerts, RBAC roles, and cluster health are all exposed - not just raw log search.", + desc: "Your Parseable instance publishes its live OSS, Enterprise, or Cloud tool catalog.", }, { icon: