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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .cursor-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "parseable",
"displayName": "Parseable",
"version": "0.2.16",
"version": "0.3.2",
"description": "Query and manage Parseable logs, metrics, datasets, and alerts from Cursor.",
"author": {
"name": "Parseable"
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ docker run -p 8787:8787 --env-file .env parseable-mcp-server
- URL: `https://mcp.your-domain.com/mcp`
- Header `X-Parseable-URL`: `https://your-parseable.example.com`
- Header `X-API-Key`: your Parseable API key
- Optional header `X-Parseable-Tool-Profile`: `core` for only `list_datasets`,
`get_dataset_schema`, and `query_sql`; omit it or use `full` for every tool
- Click Add → Connect

### 4. Connect from Claude Code
Expand Down Expand Up @@ -131,6 +133,12 @@ Each `POST /mcp` request requires `X-API-Key` and supports two modes:
| Cloud | `X-Parseable-Mode: cloud`, `X-API-Key` |
| Self-hosted (default) | `X-Parseable-URL`, `X-API-Key` |

`X-Parseable-Tool-Profile` controls tools exposed by HTTP MCP discovery. `core`
exposes only `list_datasets`, `get_dataset_schema`, and `query_sql`. `full`, or
an omitted header, exposes every tool returned by Parseable. Unknown values are
rejected with HTTP 400. The adapter applies the same profile to `tools/call`, so
hidden tools cannot be invoked through a core-profile connection.

`X-Parseable-Mode` is checked first when present. Omitting it selects self-hosted mode; clients do not need to send `X-Parseable-Mode: self-hosted`. In cloud mode, server validates API key with Parseable Cloud, caches returned URL and tenant routing in a bounded in-memory LRU for 24 hours, and sends `x-p-tenant` on Parseable requests. Cache is disposable; misses and process restarts resolve through Cloud again.

For self-hosted mode, HTTP server validates supplied URL and forwards API key to that Parseable instance. By default, private and loopback Parseable URLs are rejected to limit SSRF. Set `PARSEABLE_MCP_ALLOW_PRIVATE=true` only for trusted deployments that need private network targets.
Expand Down Expand Up @@ -185,6 +193,8 @@ 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`.
HTTP connections may filter this catalog locally with
`X-Parseable-Tool-Profile`; Parseable remains owner of the full catalog.

---

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@parseable/parseable-mcp-server",
"version": "0.3.1",
"version": "0.3.2",
"mcpName": "io.github.parseablehq/parseable-mcp-server",
"description": "Model Context Protocol server for Parseable. Lets LLMs discover and query Parseable datasets.",
"repository": {
Expand Down
4 changes: 2 additions & 2 deletions server.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@
"url": "https://github.com/parseablehq/parseable-mcp-server",
"source": "github"
},
"version": "0.2.16",
"version": "0.3.2",
"packages": [
{
"registryType": "npm",
"identifier": "@parseable/parseable-mcp-server",
"version": "0.2.16",
"version": "0.3.2",
"transport": {
"type": "stdio"
}
Expand Down
12 changes: 9 additions & 3 deletions src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,15 @@ import {
import type { ParseableClient } from "./client.js";
import { PARSEABLE_ICON_DATA_URI } from "./icon.js";
import { getRemoteTools } from "./remote-tools.js";
import { filterToolsForProfile, type ToolProfile, toolAllowed } from "./tool-profile.js";

const SERVER_VERSION = "0.2.16";
const SERVER_VERSION = "0.3.2";

function toolError(message: string): CallToolResult {
return { isError: true, content: [{ type: "text", text: message }] };
}

export function buildMcpServer(client: ParseableClient): Server {
export function buildMcpServer(client: ParseableClient, profile: ToolProfile = "full"): Server {
const server = new Server(
{
name: "parseable-mcp-server",
Expand All @@ -36,11 +37,16 @@ export function buildMcpServer(client: ParseableClient): Server {
);

server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: await getRemoteTools(client),
tools: filterToolsForProfile(profile, await getRemoteTools(client)),
}));

server.setRequestHandler(CallToolRequestSchema, async (request) => {
try {
if (!toolAllowed(profile, request.params.name)) {
return toolError(
`Tool ${request.params.name} is not available in the ${profile} tool profile.`,
);
}
const result = await client.callTool(request.params.name, request.params.arguments ?? {});
if (
!result ||
Expand Down
13 changes: 12 additions & 1 deletion src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { ParseableClient, ParseableError } from "./client.js";
import { evictCloudRouting, resolveCloudRouting } from "./cloud.js";
import type { Config } from "./config.js";
import { getRemoteTools } from "./remote-tools.js";
import { parseToolProfile, TOOL_PROFILE_HEADER, ToolProfileError } from "./tool-profile.js";

const __dirname = dirname(fileURLToPath(import.meta.url));
const UI_DIR = join(__dirname, "ui");
Expand All @@ -25,6 +26,9 @@ Required headers for static Parseable credentials:
X-API-Key
X-Parseable-Mode: cloud (cloud only; omitted means self-hosted)
X-Parseable-URL (required when mode is omitted/self-hosted)

Optional tool catalog profile:
X-Parseable-Tool-Profile: core | full (default: full)
`;

export const app = new Hono();
Expand Down Expand Up @@ -148,10 +152,11 @@ async function buildApiKeyClient(reqHeaders: Headers): Promise<{

async function handleMcpPost(c: Context) {
try {
const profile = parseToolProfile(c.req.raw.headers.get(TOOL_PROFILE_HEADER));
const built = await buildApiKeyClient(c.req.raw.headers);
await getRemoteTools(built.client);

const mcp = buildMcpServer(built.client);
const mcp = buildMcpServer(built.client, profile);

const transport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: undefined,
Expand All @@ -165,6 +170,12 @@ async function handleMcpPost(c: Context) {
headers: { "Content-Type": "application/json" },
});
}
if (err instanceof ToolProfileError) {
return new Response(JSON.stringify({ error: err.message }), {
status: err.status,
headers: { "Content-Type": "application/json" },
});
}
if (err instanceof ParseableError) {
return new Response(JSON.stringify({ error: err.message }), {
status: err.status,
Expand Down
31 changes: 31 additions & 0 deletions src/tool-profile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { RemoteTool } from "./remote-tools.js";

export const TOOL_PROFILE_HEADER = "X-Parseable-Tool-Profile";

export type ToolProfile = "core" | "full";

const CORE_TOOL_NAMES = new Set(["list_datasets", "get_dataset_schema", "query_sql"]);

export class ToolProfileError extends Error {
readonly status = 400;

constructor(value: string) {
super(`Invalid ${TOOL_PROFILE_HEADER} value "${value}". Expected "core" or "full".`);
this.name = "ToolProfileError";
}
}

export function parseToolProfile(value: string | null | undefined): ToolProfile {
if (value === undefined || value === null || value.trim() === "") return "full";
const profile = value.trim().toLowerCase();
if (profile === "core" || profile === "full") return profile;
throw new ToolProfileError(value);
}

export function toolAllowed(profile: ToolProfile, name: string): boolean {
return profile === "full" || CORE_TOOL_NAMES.has(name);
}

export function filterToolsForProfile(profile: ToolProfile, tools: RemoteTool[]): RemoteTool[] {
return profile === "full" ? tools : tools.filter((tool) => toolAllowed(profile, tool.name));
}
40 changes: 40 additions & 0 deletions test/http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,19 @@ describe("HTTP routes", () => {
);
expect(res.status).toBe(400);
});

it("POST /mcp with an invalid tool profile returns 400", async () => {
const res = await app.fetch(
mcpReq(initBody, {
...goodHeaders,
"x-parseable-tool-profile": "minimal",
}),
);
expect(res.status).toBe(400);
expect(await res.json()).toEqual({
error: 'Invalid X-Parseable-Tool-Profile value "minimal". Expected "core" or "full".',
});
});
});

describe("HTTP /mcp with mocked upstream", () => {
Expand Down Expand Up @@ -149,6 +162,33 @@ describe("HTTP /mcp with mocked upstream", () => {
expect(payload.result.serverInfo.name).toBe("parseable-mcp-server");
});

it("wires the core profile header into MCP tools/list", async () => {
vi.mocked(globalThis.fetch).mockResolvedValueOnce(
Response.json({
tools: ["list_datasets", "get_dataset_schema", "query_sql", "create_alert"].map((name) => ({
name,
inputSchema: { type: "object", properties: {} },
})),
}),
);
const res = await app.fetch(
mcpReq(
{ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} },
{ ...goodHeaders, "x-parseable-tool-profile": "core" },
),
);
expect(res.status).toBe(200);
const text = await res.text();
const dataLine = text.split("\n").find((line) => line.startsWith("data:"));
expect(dataLine).toBeDefined();
const payload = JSON.parse((dataLine as string).slice(5).trim());
expect(payload.result.tools.map((tool: { name: string }) => tool.name)).toEqual([
"list_datasets",
"get_dataset_schema",
"query_sql",
]);
});

it("rejects initialization when Parseable registry authentication fails", async () => {
vi.mocked(globalThis.fetch).mockResolvedValueOnce(
new Response("unauthorized", { status: 401, statusText: "Unauthorized" }),
Expand Down
39 changes: 39 additions & 0 deletions test/mcp_proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,45 @@ describe("MCP remote tool proxy", () => {
await server.close();
});

it("exposes only core tools and blocks calls outside the core profile", async () => {
const tools = ["list_datasets", "get_dataset_schema", "query_sql", "create_alert"].map(
(name) => ({
name,
inputSchema: { type: "object" as const, properties: {} },
}),
);
const parseable = {
baseUrl: "https://parseable.example.com",
tenantId: undefined,
apiKey: "key",
fetchToolRegistry: vi.fn().mockResolvedValue({
notModified: false,
body: { tools },
maxAgeMs: 300_000,
}),
callTool: vi.fn(),
} as unknown as ParseableClient;

const server = buildMcpServer(parseable, "core");
const client = new Client({ name: "test-client", version: "1.0.0" });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);

expect((await client.listTools()).tools.map((tool) => tool.name)).toEqual([
"list_datasets",
"get_dataset_schema",
"query_sql",
]);
expect(await client.callTool({ name: "create_alert", arguments: {} })).toMatchObject({
isError: true,
content: [{ text: expect.stringMatching(/not available in the core tool profile/) }],
});
expect(parseable.callTool).not.toHaveBeenCalled();

await client.close();
await server.close();
});

it("converts invalid upstream results into MCP tool errors", async () => {
const parseable = {
baseUrl: "https://parseable.example.com",
Expand Down
Loading