diff --git a/.changeset/tasty-cameras-grab.md b/.changeset/tasty-cameras-grab.md new file mode 100644 index 0000000..f56a1c4 --- /dev/null +++ b/.changeset/tasty-cameras-grab.md @@ -0,0 +1,5 @@ +--- +"@typesensekit/mcp": patch +--- + +Default MCP tools to read-only mode with an explicit opt out for write/admin operations. diff --git a/README.md b/README.md index ca6df56..6f3e8bc 100644 --- a/README.md +++ b/README.md @@ -57,12 +57,21 @@ tsk documents.search --input '{"collection":"production__products","params":{"q" ## MCP Server -Run the MCP stdio server directly: +Run the MCP stdio server directly. MCP tools are read-only by default, so the +assistant surface includes search, document reads, collection metadata, and +status checks without write/delete/admin operations. ```sh TYPESENSE_URL=http://localhost:8108 TYPESENSE_API_KEY=xyz pnpm dlx @typesensekit/mcp ``` +To expose the full operation registry, including writes, deletes, key +management, and raw `api.call`, opt in explicitly: + +```sh +TYPESENSEKIT_READ_ONLY=false TYPESENSE_URL=http://localhost:8108 TYPESENSE_API_KEY=xyz pnpm dlx @typesensekit/mcp +``` + Claude Desktop example: ```json diff --git a/packages/mcp/README.md b/packages/mcp/README.md index d439990..98dcb0b 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -1,9 +1,12 @@ # @typesensekit/mcp -MCP stdio server exposing Typesense API operations as tools. +MCP stdio server exposing Typesense API operations as tools. It runs in +read-only mode by default for assistant use cases. ```sh TYPESENSE_URL=http://localhost:8108 TYPESENSE_API_KEY=xyz pnpm dlx @typesensekit/mcp ``` +Set `TYPESENSEKIT_READ_ONLY=false` to expose write/delete/admin tools. + See the root README for client configuration and operation coverage. diff --git a/packages/mcp/src/env.ts b/packages/mcp/src/env.ts index e69be9c..0f41efd 100644 --- a/packages/mcp/src/env.ts +++ b/packages/mcp/src/env.ts @@ -1,4 +1,5 @@ import { serverConfigSchema } from "@typesensekit/core"; +import { readOnlyFromEnv } from "./read-only.js"; export function readEnvConfig() { return serverConfigSchema.parse({ @@ -9,3 +10,9 @@ export function readEnvConfig() { : undefined, }); } + +export function readMcpOptions() { + return { + readOnly: readOnlyFromEnv(process.env.TYPESENSEKIT_READ_ONLY), + }; +} diff --git a/packages/mcp/src/read-only.test.ts b/packages/mcp/src/read-only.test.ts new file mode 100644 index 0000000..6cdb824 --- /dev/null +++ b/packages/mcp/src/read-only.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { filterMcpOperations, readOnlyFromEnv } from "./read-only.js"; + +const operations = [ + { name: "search" }, + { name: "documents.get" }, + { name: "collections.retrieve" }, + { name: "documents.index" }, + { name: "collections.delete" }, + { name: "keys.retrieve" }, + { name: "api.call" }, +] as never[]; + +describe("MCP read-only operation filtering", () => { + it("keeps read-only tools and hides write, secret, and raw API tools", () => { + expect(filterMcpOperations(operations, true).map((op) => op.name)).toEqual([ + "search", + "documents.get", + "collections.retrieve", + ]); + }); + + it("can expose every operation when read-only mode is disabled", () => { + expect(filterMcpOperations(operations, false)).toEqual(operations); + }); + + it("defaults to read-only unless explicitly disabled", () => { + expect(readOnlyFromEnv(undefined)).toBe(true); + expect(readOnlyFromEnv("true")).toBe(true); + expect(readOnlyFromEnv("0")).toBe(false); + expect(readOnlyFromEnv("false")).toBe(false); + expect(readOnlyFromEnv("no")).toBe(false); + expect(readOnlyFromEnv("off")).toBe(false); + }); +}); diff --git a/packages/mcp/src/read-only.ts b/packages/mcp/src/read-only.ts new file mode 100644 index 0000000..d53ad5b --- /dev/null +++ b/packages/mcp/src/read-only.ts @@ -0,0 +1,52 @@ +import type { Operation } from "@typesensekit/core"; +import type { z } from "zod"; + +export const READ_ONLY_OPERATION_NAMES = new Set([ + "aliases.list", + "aliases.retrieve", + "collections.list", + "collections.retrieve", + "collections.wait", + "conversations.history.retrieve", + "conversations.models.list", + "conversations.models.retrieve", + "debug", + "documents.export", + "documents.get", + "documents.search", + "health", + "metrics", + "multi_search", + "overrides.list", + "overrides.retrieve", + "presets.list", + "presets.retrieve", + "search", + "stats", + "stopwords.list", + "stopwords.retrieve", + "synonym_sets.items.list", + "synonym_sets.items.retrieve", + "synonym_sets.list", + "synonym_sets.retrieve", + "synonyms.list", + "synonyms.retrieve", +]); + +export type McpOperation = Operation; + +export function isReadOnlyOperation(operation: McpOperation): boolean { + return READ_ONLY_OPERATION_NAMES.has(operation.name); +} + +export function filterMcpOperations( + operations: McpOperation[], + readOnly: boolean, +): McpOperation[] { + return readOnly ? operations.filter(isReadOnlyOperation) : operations; +} + +export function readOnlyFromEnv(value: string | undefined): boolean { + if (value === undefined) return true; + return !["0", "false", "no", "off"].includes(value.toLowerCase()); +} diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index 4a5c67e..1f5ff2a 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -7,7 +7,8 @@ import { } from "@typesensekit/core"; import type { z } from "zod"; import packageJson from "../package.json" with { type: "json" }; -import { readEnvConfig } from "./env.js"; +import { readEnvConfig, readMcpOptions } from "./env.js"; +import { filterMcpOperations } from "./read-only.js"; type ToolShape = z.ZodObject; @@ -16,14 +17,24 @@ function toToolShape(input: z.ZodTypeAny): z.ZodRawShape { return objectInput.shape; } -export function createTypesenseMcpServer() { +export type TypesenseMcpServerOptions = { + readOnly?: boolean; +}; + +export function createTypesenseMcpServer( + options: TypesenseMcpServerOptions = {}, +) { const server = new McpServer({ name: "typesensekit", version: packageJson.version, }); const client = createClient(readEnvConfig()); + const mcpOptions = { ...readMcpOptions(), ...options }; - for (const operation of operations) { + for (const operation of filterMcpOperations( + operations, + mcpOptions.readOnly, + )) { server.registerTool( operation.name, {