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
5 changes: 5 additions & 0 deletions .changeset/tasty-cameras-grab.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@typesensekit/mcp": patch
---

Default MCP tools to read-only mode with an explicit opt out for write/admin operations.
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion packages/mcp/README.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions packages/mcp/src/env.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { serverConfigSchema } from "@typesensekit/core";
import { readOnlyFromEnv } from "./read-only.js";

export function readEnvConfig() {
return serverConfigSchema.parse({
Expand All @@ -9,3 +10,9 @@ export function readEnvConfig() {
: undefined,
});
}

export function readMcpOptions() {
return {
readOnly: readOnlyFromEnv(process.env.TYPESENSEKIT_READ_ONLY),
};
}
35 changes: 35 additions & 0 deletions packages/mcp/src/read-only.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
52 changes: 52 additions & 0 deletions packages/mcp/src/read-only.ts
Original file line number Diff line number Diff line change
@@ -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<z.ZodTypeAny, unknown>;

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());
}
17 changes: 14 additions & 3 deletions packages/mcp/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<z.ZodRawShape>;

Expand All @@ -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,
{
Expand Down
Loading