This is an automated email from the ASF dual-hosted git repository.
hanahmily pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/skywalking-banyandb.git
The following commit(s) were added to refs/heads/main by this push:
new ccede53e6 fix(mcp): Add explicit validation for properties and tools,
and harden the server (#1036)
ccede53e6 is described below
commit ccede53e68dc9dee775c1a2916949f2afd30522a
Author: Fine0830 <[email protected]>
AuthorDate: Tue Mar 31 14:38:19 2026 +0800
fix(mcp): Add explicit validation for properties and tools, and harden the
server (#1036)
---
CHANGES.md | 1 +
dist/LICENSE | 2 +-
docs/operation/mcp/build.md | 79 ++++++--
docs/operation/mcp/setup.md | 57 +++++-
mcp/LICENSE | 2 +-
mcp/example-config.json | 8 +-
mcp/package-lock.json | 24 +--
mcp/src/client/index.ts | 23 ++-
mcp/src/config.ts | 43 +++++
mcp/src/index.ts | 454 +++-----------------------------------------
mcp/src/query/context.ts | 76 ++++++++
mcp/src/query/validation.ts | 175 +++++++++++++++++
mcp/src/server/http.ts | 196 +++++++++++++++++++
mcp/src/server/mcp.ts | 341 +++++++++++++++++++++++++++++++++
14 files changed, 1004 insertions(+), 477 deletions(-)
diff --git a/CHANGES.md b/CHANGES.md
index ab476c04a..6728bf657 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -14,6 +14,7 @@ Release Notes.
### Bug Fixes
- Fix flaky trace query filtering caused by non-deterministic sidx tag
ordering and add consistency checks for integration query cases.
+- MCP: Add validation for properties and harden the mcp server.
## 0.10.0
diff --git a/dist/LICENSE b/dist/LICENSE
index 95940cde8..cf0b6aa70 100644
--- a/dist/LICENSE
+++ b/dist/LICENSE
@@ -709,7 +709,7 @@ MIT licenses
on-finished 2.4.1 MIT
parseurl 1.3.3 MIT
path-key 3.1.1 MIT
- path-to-regexp 8.3.0 MIT
+ path-to-regexp 8.4.1 MIT
pkce-challenge 5.0.1 MIT
proxy-addr 2.0.7 MIT
range-parser 1.2.1 MIT
diff --git a/docs/operation/mcp/build.md b/docs/operation/mcp/build.md
index 57e881cdb..45f15ba10 100644
--- a/docs/operation/mcp/build.md
+++ b/docs/operation/mcp/build.md
@@ -4,7 +4,7 @@ This guide is for developers who want to build the MCP server
from source and cr
## Prerequisites
-- **Node.js 20+** installed
+- **Node.js 24.6.0+** installed
- **npm** or **yarn** package manager
- **TypeScript** knowledge (for development)
@@ -13,16 +13,22 @@ This guide is for developers who want to build the MCP
server from source and cr
```
mcp/
├── src/
-│ ├── index.ts # MCP server implementation
+│ ├── index.ts # MCP bootstrap entrypoint
+│ ├── config.ts # Environment parsing and runtime limits
│ ├── client/
-│ │ ├── index.ts # BanyanDB HTTP client
-│ │ └── types.ts # Client type definitions
+│ │ ├── index.ts # BanyanDB HTTP client
+│ │ └── types.ts # Client type definitions
│ ├── query/
-│ │ ├── llm-prompt.ts # BydbQL prompt generation
-│ │ └── types.ts # Query type definitions
+│ │ ├── context.ts # Schema/resource discovery helpers
+│ │ ├── llm-prompt.ts # BydbQL prompt generation
+│ │ ├── types.ts # Query type definitions
+│ │ └── validation.ts # Query and hint validation
+│ ├── server/
+│ │ ├── http.ts # HTTP transport, auth, and rate limiting
+│ │ └── mcp.ts # MCP prompt/tool registration and handlers
│ └── utils/
-│ ├── http.ts # HTTP utilities
-│ └── logger.ts # Logging utilities
+│ ├── http.ts # HTTP utilities
+│ └── logger.ts # Logging utilities
├── tools/
│ └── checkversion.js # Version checking utility
├── dist/ # Compiled JavaScript (generated)
@@ -34,11 +40,9 @@ mcp/
├── package.json # Node.js dependencies and scripts
├── package-lock.json # Dependency lock file
├── example-config.json # Example MCP configuration
-├── inspector-config.json # MCP Inspector configuration
├── LICENSE # License file
├── LICENSE.tpl # License template
├── Makefile # Build automation
-└── README.md # Project documentation
```
## Building from Source
@@ -58,10 +62,23 @@ npm run build
This compiles TypeScript to JavaScript in the `dist/` directory.
+The runtime is organized into small modules:
+
+- `src/index.ts` wires together configuration, transport selection, and
startup.
+- `src/server/mcp.ts` owns MCP prompt/tool registration and handlers.
+- `src/server/http.ts` owns HTTP transport, request limits, auth, and rate
limiting.
+- `src/query/validation.ts` centralizes validation for BydbQL and entity hints.
+
### 3. Verify Build
```bash
-node dist/index.js --help
+BANYANDB_ADDRESS=localhost:17900 node dist/index.js
+```
+
+The server starts in `stdio` mode by default. Use `TRANSPORT=http` if you want
to verify the HTTP transport:
+
+```bash
+TRANSPORT=http BANYANDB_ADDRESS=localhost:17900 node dist/index.js
```
## Development Mode
@@ -78,7 +95,12 @@ This runs `src/index.ts` directly using `tsx`, which is
faster for iterative dev
### 1. Make Changes
-Edit files in the `src/` directory.
+Edit the relevant files in `src/`:
+
+- transport/bootstrap changes: `src/index.ts`, `src/server/http.ts`
+- tool behavior and prompt wiring: `src/server/mcp.ts`
+- query validation: `src/query/validation.ts`
+- BanyanDB API access: `src/client/index.ts`
### 2. Format and Lint
@@ -109,11 +131,12 @@ node dist/index.js
Use MCP Inspector to test your changes:
```bash
-# Build first
+# Terminal 1: start the MCP server
npm run build
+BANYANDB_ADDRESS=localhost:17900 node dist/index.js
-# Run Inspector
-npx @modelcontextprotocol/inspector --config inspector-config.json
+# Terminal 2: run Inspector and point it at the local server command or HTTP
URL
+npx @modelcontextprotocol/inspector
```
## Debugging
@@ -133,8 +156,7 @@ Create `.vscode/launch.json` in the `mcp` directory:
"runtimeExecutable": "npx",
"runtimeArgs": [
"@modelcontextprotocol/inspector",
- "--config",
- "${workspaceFolder}/inspector-config.json"
+ "--cli"
],
"env": {
"BANYANDB_ADDRESS": "localhost:17900"
@@ -203,7 +225,7 @@ The Dockerfile includes:
- **Multi-stage build**: Separate build and production stages for smaller
final image
- **Security**: Runs as non-root user (`appuser`) for better security
- **Optimization**: Only production dependencies in final image
-- **Alpine-based**: Uses `node:20-alpine` for minimal image size
+- **Runtime validation**: Supports HTTP safety controls such as `MCP_HOST`,
`MCP_AUTH_TOKEN`, request size limits, and rate limiting
#### Build Arguments
@@ -226,6 +248,18 @@ docker run --rm \
apache/skywalking-banyandb-mcp:latest
```
+To test HTTP mode in Docker:
+
+```bash
+docker run --rm \
+ -p 3000:3000 \
+ -e TRANSPORT=http \
+ -e MCP_HOST=0.0.0.0 \
+ -e MCP_AUTH_TOKEN=replace-with-a-strong-random-token \
+ -e BANYANDB_ADDRESS=host.docker.internal:17900 \
+ apache/skywalking-banyandb-mcp:latest
+```
+
#### Publishing Docker Image
To publish the image to a registry:
@@ -293,7 +327,13 @@ Test with real BanyanDB instance:
2. Run MCP Inspector:
```bash
npm run build
- npx @modelcontextprotocol/inspector --config inspector-config.json
+ BANYANDB_ADDRESS=localhost:17900 node dist/index.js
+ ```
+
+ In another terminal:
+
+ ```bash
+ npx @modelcontextprotocol/inspector
```
3. Test various queries through the Inspector UI
@@ -358,4 +398,3 @@ Both linting and formatting are integrated into the build
process via the Makefi
- [TypeScript Documentation](https://www.typescriptlang.org/docs/)
- [MCP SDK
Documentation](https://github.com/modelcontextprotocol/typescript-sdk)
- [Node.js Documentation](https://nodejs.org/docs/)
-
diff --git a/docs/operation/mcp/setup.md b/docs/operation/mcp/setup.md
index 53e281b0c..542904cc1 100644
--- a/docs/operation/mcp/setup.md
+++ b/docs/operation/mcp/setup.md
@@ -4,7 +4,7 @@ This guide explains how to set up and use the BanyanDB MCP
server from pre-built
## Prerequisites
-- **Node.js 20+** installed (for binary usage)
+- **Node.js 24.6.0+** installed (for binary usage)
- **BanyanDB** running and accessible
- **MCP client** (e.g., Claude Desktop, MCP Inspector, or other MCP-compatible
clients)
@@ -26,6 +26,14 @@ The server starts in stdio mode by default and waits for MCP
client connections.
Set the following environment variables:
- `BANYANDB_ADDRESS`: BanyanDB server address (default: `localhost:17900`).
The server auto-converts gRPC port (17900) to HTTP port (17913).
+- `TRANSPORT`: Transport mode. Default is `stdio`. Set `http` only when you
want to expose the MCP server over HTTP.
+- `MCP_HOST`: HTTP listen address. Default is `127.0.0.1`.
+- `MCP_PORT`: HTTP listen port. Default is `3000`.
+- `MCP_AUTH_TOKEN`: Optional bearer token for HTTP mode. Required when
`MCP_HOST` is not a loopback address.
+- `MCP_MAX_BODY_BYTES`: Maximum HTTP request body size. Default is `1048576`
(1 MiB).
+- `MCP_RATE_LIMIT_WINDOW_MS`: Per-client HTTP rate limit window in
milliseconds. Default is `60000`.
+- `MCP_RATE_LIMIT_MAX_REQUESTS`: Maximum HTTP requests allowed per client in
each rate-limit window. Default is `60`.
+- `MCP_RATE_LIMIT_MAX_CLIENTS`: Maximum number of client IDs tracked by the
in-memory HTTP rate limiter. Default is `10000`.
**Address formats:**
- `localhost:17900` - Local BanyanDB
@@ -112,7 +120,13 @@ services:
|----------|----------|---------|-------------|
| `BANYANDB_ADDRESS` | No | `localhost:17900` | BanyanDB server address.
Auto-converts gRPC port (17900) to HTTP port (17913). |
| `TRANSPORT` | No | `stdio` | Transport mode: `stdio` for standard I/O
(default), `http` for Streamable HTTP. |
+| `MCP_HOST` | No | `127.0.0.1` | HTTP listen address. Only used when
`TRANSPORT=http`. |
| `MCP_PORT` | No | `3000` | HTTP port to listen on. Only used when
`TRANSPORT=http`. |
+| `MCP_AUTH_TOKEN` | Conditionally | unset | Optional bearer token for HTTP
mode. Required when `MCP_HOST` is not a loopback address. |
+| `MCP_MAX_BODY_BYTES` | No | `1048576` | Maximum HTTP request body size in
bytes. |
+| `MCP_RATE_LIMIT_WINDOW_MS` | No | `60000` | Per-client HTTP rate-limit
window in milliseconds. |
+| `MCP_RATE_LIMIT_MAX_REQUESTS` | No | `60` | Maximum HTTP requests allowed
per client during each rate-limit window. |
+| `MCP_RATE_LIMIT_MAX_CLIENTS` | No | `10000` | Maximum number of client IDs
retained by the in-memory HTTP rate limiter before oldest entries are evicted. |
### Verifying BanyanDB Connection
@@ -134,21 +148,37 @@ By default the MCP server communicates over standard I/O
(`TRANSPORT=stdio`), wh
### Start in HTTP Mode
```bash
-# Default port 3000
+# Default loopback bind
TRANSPORT=http BANYANDB_ADDRESS=localhost:17900 node dist/index.js
-# Custom port
-TRANSPORT=http MCP_PORT=8080 BANYANDB_ADDRESS=localhost:17900 node
dist/index.js
+# Custom host and port
+TRANSPORT=http MCP_HOST=127.0.0.1 MCP_PORT=8080
BANYANDB_ADDRESS=localhost:17900 node dist/index.js
+
+# Non-loopback host requires an auth token
+TRANSPORT=http \
+MCP_HOST=0.0.0.0 \
+MCP_PORT=3000 \
+MCP_AUTH_TOKEN="$(openssl rand -hex 32)" \
+BANYANDB_ADDRESS=localhost:17900 \
+node dist/index.js
```
The server prints the listening address on startup:
```
-BanyanDB MCP HTTP server listening on :3000/mcp
+BanyanDB MCP HTTP server listening on 127.0.0.1:3000/mcp
```
The single endpoint is `POST /mcp`. Other HTTP methods on `/mcp` return `405
Method Not Allowed`, and all other paths return `404`.
+If `MCP_AUTH_TOKEN` is configured, clients must send:
+
+```http
+Authorization: Bearer <token>
+```
+
+HTTP mode also enforces request-size and per-client rate limits using
`MCP_MAX_BODY_BYTES`, `MCP_RATE_LIMIT_WINDOW_MS`,
`MCP_RATE_LIMIT_MAX_REQUESTS`, and `MCP_RATE_LIMIT_MAX_CLIENTS`.
+
### Configure an MCP Client for HTTP
MCP clients that support the Streamable HTTP transport (e.g. MCP Inspector,
custom integrations) connect via a URL:
@@ -157,12 +187,17 @@ MCP clients that support the Streamable HTTP transport
(e.g. MCP Inspector, cust
{
"mcpServers": {
"banyandb": {
- "url": "http://localhost:3000/mcp"
+ "url": "http://localhost:3000/mcp",
+ "headers": {
+ "Authorization": "Bearer replace-with-your-token"
+ }
}
}
}
```
+If you keep the default loopback bind and do not set `MCP_AUTH_TOKEN`, the
`headers` block is not required.
+
### Docker with HTTP Mode
When running in a container, expose the HTTP port and set the transport env
var:
@@ -172,7 +207,9 @@ docker run -d \
--name banyandb-mcp \
-p 3000:3000 \
-e TRANSPORT=http \
+ -e MCP_HOST=0.0.0.0 \
-e MCP_PORT=3000 \
+ -e MCP_AUTH_TOKEN=replace-with-a-strong-random-token \
-e BANYANDB_ADDRESS=banyandb:17900 \
ghcr.io/apache/skywalking-banyandb-mcp:{COMMIT_ID}
```
@@ -194,7 +231,9 @@ services:
container_name: banyandb-mcp
environment:
- TRANSPORT=http
+ - MCP_HOST=0.0.0.0
- MCP_PORT=3000
+ - MCP_AUTH_TOKEN=replace-with-a-strong-random-token
- BANYANDB_ADDRESS=banyandb:17900
ports:
- "3000:3000"
@@ -219,9 +258,13 @@ services:
- For Docker, ensure containers are on the same network
**"Command not found: node":**
-- Install Node.js 20+ from [nodejs.org](https://nodejs.org/)
+- Install Node.js 24.6.0+ from [nodejs.org](https://nodejs.org/)
- Or use Docker image instead
+**HTTP mode exits at startup:**
+- If `MCP_HOST` is not `127.0.0.1`, `localhost`, or `::1`, set `MCP_AUTH_TOKEN`
+- Verify `MCP_PORT`, `MCP_MAX_BODY_BYTES`, `MCP_RATE_LIMIT_WINDOW_MS`,
`MCP_RATE_LIMIT_MAX_REQUESTS`, and `MCP_RATE_LIMIT_MAX_CLIENTS` are positive
integers
+
**MCP server not appearing in client:**
- Verify JSON config is valid
- Use absolute path to binary
diff --git a/mcp/LICENSE b/mcp/LICENSE
index 032e16ccb..e98f9981b 100644
--- a/mcp/LICENSE
+++ b/mcp/LICENSE
@@ -113,7 +113,7 @@ MIT licenses
on-finished 2.4.1 MIT
parseurl 1.3.3 MIT
path-key 3.1.1 MIT
- path-to-regexp 8.3.0 MIT
+ path-to-regexp 8.4.1 MIT
pkce-challenge 5.0.1 MIT
proxy-addr 2.0.7 MIT
range-parser 1.2.1 MIT
diff --git a/mcp/example-config.json b/mcp/example-config.json
index d8325a087..7092663f2 100644
--- a/mcp/example-config.json
+++ b/mcp/example-config.json
@@ -4,7 +4,13 @@
"command": "node",
"args": ["/absolute/path/to/skywalking-banyandb/mcp/dist/index.js"],
"env": {
- "BANYANDB_ADDRESS": "localhost:17900"
+ "BANYANDB_ADDRESS": "localhost:17900",
+ "TRANSPORT": "stdio",
+ "MCP_HOST": "127.0.0.1",
+ "MCP_MAX_BODY_BYTES": "1048576",
+ "MCP_RATE_LIMIT_WINDOW_MS": "60000",
+ "MCP_RATE_LIMIT_MAX_REQUESTS": "60",
+ "MCP_RATE_LIMIT_MAX_CLIENTS": "10000"
}
}
}
diff --git a/mcp/package-lock.json b/mcp/package-lock.json
index 31eec86e2..348ffbc28 100644
--- a/mcp/package-lock.json
+++ b/mcp/package-lock.json
@@ -523,9 +523,9 @@
"license": "MIT"
},
"node_modules/@eslint/config-array/node_modules/brace-expansion": {
- "version": "1.1.12",
- "resolved":
"https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
- "integrity":
"sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+ "version": "1.1.13",
+ "resolved":
"https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
+ "integrity":
"sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -621,9 +621,9 @@
"license": "MIT"
},
"node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
- "version": "1.1.12",
- "resolved":
"https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
- "integrity":
"sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+ "version": "1.1.13",
+ "resolved":
"https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
+ "integrity":
"sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1641,9 +1641,9 @@
"license": "MIT"
},
"node_modules/eslint/node_modules/brace-expansion": {
- "version": "1.1.12",
- "resolved":
"https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
- "integrity":
"sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+ "version": "1.1.13",
+ "resolved":
"https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
+ "integrity":
"sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2636,9 +2636,9 @@
}
},
"node_modules/path-to-regexp": {
- "version": "8.3.0",
- "resolved":
"https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
- "integrity":
"sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
+ "version": "8.4.1",
+ "resolved":
"https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.1.tgz",
+ "integrity":
"sha512-fvU78fIjZ+SBM9YwCknCvKOUKkLVqtWDVctl0s7xIqfmfb38t2TT4ZU2gHm+Z8xGwgW+QWEU3oQSAzIbo89Ggw==",
"license": "MIT",
"funding": {
"type": "opencollective",
diff --git a/mcp/src/client/index.ts b/mcp/src/client/index.ts
index 5617efb69..d94ff1a71 100644
--- a/mcp/src/client/index.ts
+++ b/mcp/src/client/index.ts
@@ -20,6 +20,17 @@
import type { QueryRequest, QueryResponse, Group, ResourceMetadata } from
'./types.js';
import { httpFetch } from '../utils/http.js';
+const maxErrorBodyLength = 2048;
+const maxToolResultLength = 32 * 1024;
+
+function truncateText(text: string, maxLength: number): string {
+ if (text.length <= maxLength) {
+ return text;
+ }
+
+ return `${text.slice(0, maxLength)}\n... [truncated ${text.length -
maxLength} characters]`;
+}
+
/**
* BanyanDBClient wraps the BanyanDB HTTP client for executing queries.
* Uses the HTTP API (grpc-gateway) instead of gRPC to avoid proto dependency
issues.
@@ -79,7 +90,7 @@ export class BanyanDBClient {
const errorResponse = (data as { errors: Response }).errors;
const errorText = await errorResponse.text().catch(() =>
errorResponse.statusText);
throw new Error(
- `Query execution failed: ${errorResponse.status}
${errorResponse.statusText}\n\n${queryDebugInfo}\n\nResponse: ${errorText}`,
+ `Query execution failed: ${errorResponse.status}
${errorResponse.statusText}\n\n${queryDebugInfo}\n\nResponse:
${truncateText(errorText, maxErrorBodyLength)}`,
);
}
@@ -168,7 +179,7 @@ export class BanyanDBClient {
if (streamResult.elements && Array.isArray(streamResult.elements) &&
streamResult.elements.length === 0) {
return 'Stream Query Result: No data found (empty result set)';
}
- return `Stream Query Result:\n${JSON.stringify(streamResult, null, 2)}`;
+ return truncateText(`Stream Query
Result:\n${JSON.stringify(streamResult, null, 2)}`, maxToolResultLength);
}
if (measureResult) {
@@ -188,7 +199,7 @@ export class BanyanDBClient {
'3. Verify data was written to BanyanDB for this measure'
);
}
- return `Measure Query Result:\n${JSON.stringify(dataPoints, null, 2)}`;
+ return truncateText(`Measure Query Result:\n${JSON.stringify(dataPoints,
null, 2)}`, maxToolResultLength);
}
if (traceResult) {
@@ -196,7 +207,7 @@ export class BanyanDBClient {
if (traceResult.elements && Array.isArray(traceResult.elements) &&
traceResult.elements.length === 0) {
return 'Trace Query Result: No data found (empty result set)';
}
- return `Trace Query Result:\n${JSON.stringify(traceResult, null, 2)}`;
+ return truncateText(`Trace Query Result:\n${JSON.stringify(traceResult,
null, 2)}`, maxToolResultLength);
}
if (propertyResult) {
@@ -204,7 +215,7 @@ export class BanyanDBClient {
if (propertyResult.items && Array.isArray(propertyResult.items) &&
propertyResult.items.length === 0) {
return 'Property Query Result: No data found (empty result set)';
}
- return `Property Query Result:\n${JSON.stringify(propertyResult, null,
2)}`;
+ return truncateText(`Property Query
Result:\n${JSON.stringify(propertyResult, null, 2)}`, maxToolResultLength);
}
if (topnResult) {
@@ -212,7 +223,7 @@ export class BanyanDBClient {
if (topnResult.lists && Array.isArray(topnResult.lists) &&
topnResult.lists.length === 0) {
return 'TopN Query Result: No data found (empty result set)';
}
- return `TopN Query Result:\n${JSON.stringify(topnResult, null, 2)}`;
+ return truncateText(`TopN Query Result:\n${JSON.stringify(topnResult,
null, 2)}`, maxToolResultLength);
}
return 'Unknown result type';
diff --git a/mcp/src/config.ts b/mcp/src/config.ts
new file mode 100644
index 000000000..9e345a445
--- /dev/null
+++ b/mcp/src/config.ts
@@ -0,0 +1,43 @@
+/**
+ * Licensed to Apache Software Foundation (ASF) under one or more contributor
+ * license agreements. See the NOTICE file distributed with this work for
+ * additional information regarding copyright ownership. Apache Software
+ * Foundation (ASF) licenses this file to you under the Apache License,
+ * Version 2.0 (the "License"); you may not use this file except in
+ * compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export const BANYANDB_ADDRESS = process.env.BANYANDB_ADDRESS ||
'localhost:17900';
+export const TRANSPORT = process.env.TRANSPORT || 'stdio';
+export const MCP_HOST = process.env.MCP_HOST || '127.0.0.1';
+export const MCP_AUTH_TOKEN = process.env.MCP_AUTH_TOKEN?.trim() || '';
+
+function parsePositiveInteger(rawValue: string | undefined, defaultValue:
string, fieldName: string): number {
+ const parsedValue = parseInt(rawValue || defaultValue, 10);
+ if (!Number.isFinite(parsedValue) || parsedValue <= 0) {
+ throw new Error(`Invalid ${fieldName} value "${rawValue}": must be a
positive integer.`);
+ }
+
+ return parsedValue;
+}
+
+const mcpPortRaw = parseInt(process.env.MCP_PORT || '3000', 10);
+if (!Number.isFinite(mcpPortRaw) || mcpPortRaw <= 0 || mcpPortRaw > 65535) {
+ throw new Error(`Invalid MCP_PORT value "${process.env.MCP_PORT}": must be
an integer between 1 and 65535.`);
+}
+
+export const MCP_PORT = mcpPortRaw;
+export const MCP_MAX_BODY_BYTES =
parsePositiveInteger(process.env.MCP_MAX_BODY_BYTES, `${1024 * 1024}`,
'MCP_MAX_BODY_BYTES');
+export const MAX_ERROR_MESSAGE_LENGTH = 1024;
+export const MAX_TOOL_RESPONSE_LENGTH = 64 * 1024;
+export const MCP_RATE_LIMIT_WINDOW_MS =
parsePositiveInteger(process.env.MCP_RATE_LIMIT_WINDOW_MS, '60000',
'MCP_RATE_LIMIT_WINDOW_MS');
+export const MCP_RATE_LIMIT_MAX_REQUESTS =
parsePositiveInteger(process.env.MCP_RATE_LIMIT_MAX_REQUESTS, '60',
'MCP_RATE_LIMIT_MAX_REQUESTS');
+export const MCP_RATE_LIMIT_MAX_CLIENTS =
parsePositiveInteger(process.env.MCP_RATE_LIMIT_MAX_CLIENTS, '10000',
'MCP_RATE_LIMIT_MAX_CLIENTS');
diff --git a/mcp/src/index.ts b/mcp/src/index.ts
index dae903ed4..7eec4341d 100644
--- a/mcp/src/index.ts
+++ b/mcp/src/index.ts
@@ -1,456 +1,52 @@
/**
* Licensed to Apache Software Foundation (ASF) under one or more contributor
- * license agreements. See the NOTICE file distributed with
- * this work for additional information regarding copyright
- * ownership. Apache Software Foundation (ASF) licenses this file to you under
- * the Apache License, Version 2.0 (the "License"); you may
- * not use this file except in compliance with the License.
- * You may obtain a copy of the License at
+ * license agreements. See the NOTICE file distributed with this work for
+ * additional information regarding copyright ownership. Apache Software
+ * Foundation (ASF) licenses this file to you under the Apache License,
+ * Version 2.0 (the "License"); you may not use this file except in
+ * compliance with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
*/
-import { createServer } from 'node:http';
-
import dotenv from 'dotenv';
-import { z } from 'zod';
-import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from
'@modelcontextprotocol/sdk/server/stdio.js';
-import { StreamableHTTPServerTransport } from
'@modelcontextprotocol/sdk/server/streamableHttp.js';
-import { CallToolRequestSchema, ListToolsRequestSchema } from
'@modelcontextprotocol/sdk/types.js';
-import { BanyanDBClient, ResourceMetadata } from './client/index.js';
-import { generateBydbQL } from './query/llm-prompt.js';
-import { ResourcesByGroup } from './query/types.js';
+import { BanyanDBClient } from './client/index.js';
+import { BANYANDB_ADDRESS, MCP_AUTH_TOKEN, MCP_HOST, TRANSPORT } from
'./config.js';
+import { isLoopbackHost, startHttpServer } from './server/http.js';
+import { createMcpServer } from './server/mcp.js';
import { log, setupGlobalErrorHandlers } from './utils/logger.js';
-// Load environment variables first
dotenv.config();
-
-// Set up global error handlers early to catch all errors
setupGlobalErrorHandlers();
-const BANYANDB_ADDRESS = process.env.BANYANDB_ADDRESS || 'localhost:17900';
-const TRANSPORT = process.env.TRANSPORT || 'stdio';
-const mcpPortRaw = parseInt(process.env.MCP_PORT || '3000', 10);
-if (!Number.isFinite(mcpPortRaw) || mcpPortRaw <= 0 || mcpPortRaw > 65535) {
- throw new Error(`Invalid MCP_PORT value "${process.env.MCP_PORT}": must be
an integer between 1 and 65535.`);
-}
-const MCP_PORT = mcpPortRaw;
-
-// Prompt schema for generate_BydbQL — used with registerPrompt which requires
a type cast
-// due to MCP SDK 1.x Zod v3/v4 compatibility layer causing TS2589 deep type
instantiation.
-const generateBydbQLPromptSchema = {
- description: z.string().describe(
- "Natural language description of the query (e.g., 'list the last 30
minutes service_cpm_minute', 'show the last 30 zipkin spans order by time')",
- ),
- resource_type: z.enum(['stream', 'measure', 'trace',
'property']).optional().describe('Optional resource type hint: stream, measure,
trace, or property'),
- resource_name: z.string().optional().describe('Optional resource name hint
(stream/measure/trace/property name)'),
- group: z.string().optional().describe('Optional group hint, for example the
properties group'),
-} as const;
-
-type QueryHints = {
- description?: string;
- BydbQL?: string;
- resource_type?: string;
- resource_name?: string;
- group?: string;
-};
-
-function normalizeQueryHints(args: unknown): QueryHints {
- if (!args || typeof args !== 'object') {
- return {};
- }
-
- const rawArgs = args as Record<string, unknown>;
- return {
- description: typeof rawArgs.description === 'string' ?
rawArgs.description.trim() : undefined,
- BydbQL: typeof rawArgs.BydbQL === 'string' ? rawArgs.BydbQL.trim() :
undefined,
- resource_type: typeof rawArgs.resource_type === 'string' ?
rawArgs.resource_type.trim() : undefined,
- resource_name: typeof rawArgs.resource_name === 'string' ?
rawArgs.resource_name.trim() : undefined,
- group: typeof rawArgs.group === 'string' ? rawArgs.group.trim() :
undefined,
- };
-}
-
-async function loadQueryContext(banyandbClient: BanyanDBClient): Promise<{
groups: string[]; resourcesByGroup: ResourcesByGroup }> {
- let groups: string[] = [];
- try {
- const groupsList = await banyandbClient.listGroups();
- groups = groupsList.map((groupResource) => groupResource.metadata?.name ||
'').filter((groupName) => groupName !== '');
- } catch (error) {
- log.warn('Failed to fetch groups, continuing without group information:',
error instanceof Error ? error.message : String(error));
- }
-
- const resourcesByGroup: ResourcesByGroup = {};
- for (const group of groups) {
- try {
- const [streams, measures, traces, properties, topNItems, indexRule] =
await Promise.all([
- banyandbClient.listStreams(group).catch(() => []),
- banyandbClient.listMeasures(group).catch(() => []),
- banyandbClient.listTraces(group).catch(() => []),
- banyandbClient.listProperties(group).catch(() => []),
- banyandbClient.listTopN(group).catch(() => []),
- banyandbClient.listIndexRule(group).catch(() => []),
- ]);
-
- resourcesByGroup[group] = {
- streams: streams.map((resource) => resource.metadata?.name ||
'').filter((resourceName) => resourceName !== ''),
- measures: measures.map((resource) => resource.metadata?.name ||
'').filter((resourceName) => resourceName !== ''),
- traces: traces.map((resource) => resource.metadata?.name ||
'').filter((resourceName) => resourceName !== ''),
- properties: properties.map((resource) => resource.metadata?.name ||
'').filter((resourceName) => resourceName !== ''),
- topNItems: topNItems.map((resource) => resource.metadata?.name ||
'').filter((resourceName) => resourceName !== ''),
- indexRule: indexRule.filter((resource) => !resource.noSort &&
resource.metadata?.name).map((resource) => resource.metadata?.name || ''),
- };
- } catch (error) {
- log.warn(`Failed to fetch resources for group "${group}", continuing:`,
error instanceof Error ? error.message : String(error));
- resourcesByGroup[group] = { streams: [], measures: [], traces: [],
properties: [], topNItems: [], indexRule: [] };
- }
- }
-
- return { groups, resourcesByGroup };
-}
-
-function createMcpServer(banyandbClient: BanyanDBClient): McpServer {
- const server = new McpServer(
- {
- name: 'banyandb-mcp',
- version: '1.0.0',
- },
- {
- capabilities: {
- tools: {},
- },
- },
- );
-
- const registerPrompt = server.registerPrompt.bind(server) as unknown as (
- name: string,
- config: {
- description: string;
- argsSchema: unknown;
- },
- cb: (args: { description: string; resource_type?: string; resource_name?:
string; group?: string }) => Promise<{
- messages: Array<{
- role: 'user';
- content: {
- type: 'text';
- text: string;
- };
- }>;
- }>,
- ) => unknown;
-
- registerPrompt(
- 'generate_BydbQL',
- {
- description:
- 'Generate the prompt/context needed to derive correct BydbQL from
natural language and BanyanDB schema hints. Use list_groups_schemas first to
discover available resources.',
- argsSchema: generateBydbQLPromptSchema,
- },
- async (args) => {
- const description = args.description.trim();
- if (!description) {
- throw new Error('description is required');
- }
-
- const { groups, resourcesByGroup } = await
loadQueryContext(banyandbClient);
- const prompt = generateBydbQL(description, args, groups,
resourcesByGroup);
-
- return {
- messages: [
- {
- role: 'user',
- content: {
- type: 'text',
- text: prompt,
- },
- },
- ],
- };
- },
- );
-
- // List available tools
- server.server.setRequestHandler(ListToolsRequestSchema, async () => {
- return {
- tools: [
- {
- name: 'list_groups_schemas',
- description:
- 'List available resources in BanyanDB (groups, streams, measures,
traces, properties). Use this to discover what resources exist before
querying.',
- inputSchema: {
- type: 'object',
- properties: {
- resource_type: {
- type: 'string',
- description: 'Type of resource to list: groups, streams,
measures, traces, or properties',
- enum: ['groups', 'streams', 'measures', 'traces',
'properties'],
- },
- group: {
- type: 'string',
- description: 'Group name (required for streams, measures,
traces, and properties)',
- },
- },
- required: ['resource_type'],
- },
- },
- {
- name: 'list_resources_bydbql',
- description: 'Fetch streams, measures, traces, or properties from
BanyanDB using a BydbQL query.',
- inputSchema: {
- type: 'object',
- properties: {
- BydbQL: {
- type: 'string',
- description: 'BydbQL query to execute against BanyanDB.',
- },
- resource_type: {
- type: 'string',
- description: 'Optional resource type hint: stream, measure,
trace, or property',
- enum: ['stream', 'measure', 'trace', 'property'],
- },
- resource_name: {
- type: 'string',
- description: 'Optional resource name hint
(stream/measure/trace/property name)',
- },
- group: {
- type: 'string',
- description: 'Optional group hint, for example the properties
group',
- },
- },
- required: ['BydbQL'],
- },
- },
- {
- name: 'get_generate_bydbql_prompt',
- description:
- 'Return the full prompt text used by generate_BydbQL, including
live BanyanDB schema hints. This lets external projects retrieve the prompt
text directly.',
- inputSchema: {
- type: 'object',
- properties: {
- description: {
- type: 'string',
- description:
- "Natural language description of the query (e.g., 'list the
last 30 minutes service_cpm_minute', 'show the last 30 zipkin spans order by
time')",
- },
- resource_type: {
- type: 'string',
- description: 'Optional resource type hint: stream, measure,
trace, or property',
- enum: ['stream', 'measure', 'trace', 'property'],
- },
- resource_name: {
- type: 'string',
- description: 'Optional resource name hint
(stream/measure/trace/property name)',
- },
- group: {
- type: 'string',
- description: 'Optional group hint, for example the properties
group',
- },
- },
- required: ['description'],
- },
- },
- ],
- };
- });
-
- // Handle tool calls
- server.server.setRequestHandler(CallToolRequestSchema, async (request) => {
- const { name, arguments: args } = request.params;
-
- if (name === 'list_groups_schemas') {
- const resourceType = (args as Record<string, unknown>)?.resource_type as
string | undefined;
- if (!resourceType) {
- throw new Error('resource_type is required and must be one of: groups,
streams, measures, traces, properties');
- }
-
- let result: string;
-
- if (resourceType === 'groups') {
- const groups = await banyandbClient.listGroups();
- const groupNames = groups.map((g) => g.metadata?.name ||
'').filter((n) => n !== '');
- result = `Available Groups
(${groupNames.length}):\n${groupNames.join('\n')}`;
- if (groupNames.length === 0) {
- result += '\n\nNo groups found. BanyanDB may be empty or not
configured.';
- }
- } else {
- const group = (args as Record<string, unknown>)?.group as string |
undefined;
- if (!group) {
- throw new Error(`group is required for listing ${resourceType}`);
- }
-
- const resourceFetchers: Record<string, () =>
Promise<ResourceMetadata[]>> = {
- streams: () => banyandbClient.listStreams(group),
- measures: () => banyandbClient.listMeasures(group),
- traces: () => banyandbClient.listTraces(group),
- properties: () => banyandbClient.listProperties(group),
- };
-
- const fetcher = resourceFetchers[resourceType];
- if (!fetcher) {
- throw new Error(`Invalid resource_type "${resourceType}". Must be
one of: groups, streams, measures, traces, properties`);
- }
-
- const resources = await fetcher();
- const resourceNames = resources.map((r) => r.metadata?.name ||
'').filter((n) => n !== '');
- const resourceLabel = resourceType.charAt(0).toUpperCase() +
resourceType.slice(1);
-
- result = `Available ${resourceLabel} in group "${group}"
(${resourceNames.length}):\n${resourceNames.join('\n')}`;
- if (resourceNames.length === 0) {
- result += `\n\nNo ${resourceType} found in group "${group}".`;
- }
- }
-
- return { content: [{ type: 'text', text: result }] };
- }
-
- if (name === 'list_resources_bydbql') {
- const queryHints = normalizeQueryHints(args);
- if (!queryHints.BydbQL) {
- throw new Error('BydbQL is required');
- }
-
- try {
- const result = await banyandbClient.query(queryHints.BydbQL);
- const debugParts: string[] = [];
-
- if (queryHints.resource_type) debugParts.push(`Resource Type:
${queryHints.resource_type}`);
- if (queryHints.resource_name) debugParts.push(`Resource Name:
${queryHints.resource_name}`);
- if (queryHints.group) debugParts.push(`Group: ${queryHints.group}`);
-
- const debugInfo = debugParts.length > 0 ? `\n\n=== Debug Information
===\n${debugParts.join('\n')}` : '';
- const text = `=== Query Result ===\n\n${result}\n\n=== BydbQL Query
===\n${queryHints.BydbQL}${debugInfo}`;
-
- return { content: [{ type: 'text', text }] };
- } catch (error) {
- if (error instanceof Error) {
- if (error.message.includes('timeout') ||
error.message.includes('Timeout')) {
- return {
- content: [
- {
- type: 'text',
- text:
- `Query timeout: ${error.message}\n\n` +
- `Possible causes:\n` +
- `- BanyanDB is not running or not accessible\n` +
- `- Network connectivity issues\n` +
- `- BanyanDB is overloaded or slow\n\n` +
- `Try:\n` +
- `1. Verify BanyanDB is running: curl
http://localhost:17913/api/healthz\n` +
- `2. Check network connectivity\n` +
- `3. Use list_groups_schemas to verify BanyanDB is
accessible`,
- },
- ],
- };
- }
- if (error.message.includes('not found') ||
error.message.includes('does not exist') || error.message.includes('Empty
response')) {
- return {
- content: [
- {
- type: 'text',
- text:
- `Query failed: ${error.message}\n\n` +
- `Tip: Use the list_groups_schemas tool to discover
available resources:\n` +
- `- First list groups: list_groups_schemas with
resource_type="groups"\n` +
- `- Then list streams, measures, traces, or properties for
a target group\n` +
- `- Then query using a natural language description and
optional resource_type, resource_name, or group hints.`,
- },
- ],
- };
- }
- throw error;
- }
- throw new Error(`Query execution failed: ${String(error)}`);
- }
- }
-
- if (name === 'get_generate_bydbql_prompt') {
- const queryHints = normalizeQueryHints(args);
- if (!queryHints.description) {
- throw new Error('description is required');
- }
-
- const { groups, resourcesByGroup } = await
loadQueryContext(banyandbClient);
- const prompt = generateBydbQL(queryHints.description, queryHints,
groups, resourcesByGroup);
-
- return { content: [{ type: 'text', text: prompt }] };
- }
-
- throw new Error(`Unknown tool: ${name}`);
- });
-
- return server;
-}
-
async function main() {
const banyandbClient = new BanyanDBClient(BANYANDB_ADDRESS);
log.info('Using built-in BydbQL prompt generation with natural language
support and BanyanDB schema hints. Use the list_groups_schemas tool to discover
available resources and guide your queries.');
if (TRANSPORT === 'http') {
- const httpServer = createServer((req, res) => {
- const { pathname } = new URL(req.url ?? '', 'http://localhost');
- if (pathname !== '/mcp') {
- res.writeHead(404, { 'Content-Type': 'application/json' });
- res.end(JSON.stringify({ error: 'Not found' }));
- return;
- }
-
- if (req.method !== 'POST') {
- res.writeHead(405, {
- 'Content-Type': 'application/json',
- Allow: 'POST',
- });
- res.end(JSON.stringify({ error: 'Method not allowed' }));
- return;
- }
+ if (!isLoopbackHost(MCP_HOST) && !MCP_AUTH_TOKEN) {
+ throw new Error('MCP_AUTH_TOKEN is required when MCP_HOST is not a
loopback address.');
+ }
- let body = '';
- req.on('data', (chunk) => { body += chunk; });
- req.on('end', async () => {
- let parsedBody: unknown;
- if (body) {
- try {
- parsedBody = JSON.parse(body);
- } catch {
- res.writeHead(400, { 'Content-Type': 'application/json' });
- res.end(JSON.stringify({ error: 'Invalid JSON in request body' }));
- return;
- }
- }
- try {
- const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined });
- const mcpServer = createMcpServer(banyandbClient);
- await mcpServer.connect(transport);
- await transport.handleRequest(req, res, parsedBody);
- } catch (error) {
- if (!res.headersSent) {
- res.writeHead(500, { 'Content-Type': 'application/json' });
- res.end(JSON.stringify({ error: error instanceof Error ?
error.message : String(error) }));
- }
- }
- });
- });
+ startHttpServer(banyandbClient);
+ return;
+ }
- httpServer.listen(MCP_PORT, () => {
- log.info(`BanyanDB MCP HTTP server listening on :${MCP_PORT}/mcp`);
- log.info(`Connecting to BanyanDB at ${BANYANDB_ADDRESS}`);
- });
- } else {
- const mcpServer = createMcpServer(banyandbClient);
- const transport = new StdioServerTransport();
- await mcpServer.connect(transport);
+ const mcpServer = createMcpServer(banyandbClient);
+ const transport = new StdioServerTransport();
+ await mcpServer.connect(transport);
- log.info('BanyanDB MCP server started');
- log.info(`Connecting to BanyanDB at ${BANYANDB_ADDRESS}`);
- }
+ log.info('BanyanDB MCP server started');
+ log.info(`Connecting to BanyanDB at ${BANYANDB_ADDRESS}`);
}
main().catch((error) => {
diff --git a/mcp/src/query/context.ts b/mcp/src/query/context.ts
new file mode 100644
index 000000000..5cfcd961b
--- /dev/null
+++ b/mcp/src/query/context.ts
@@ -0,0 +1,76 @@
+/**
+ * Licensed to Apache Software Foundation (ASF) under one or more contributor
+ * license agreements. See the NOTICE file distributed with this work for
+ * additional information regarding copyright ownership. Apache Software
+ * Foundation (ASF) licenses this file to you under the Apache License,
+ * Version 2.0 (the "License"); you may not use this file except in
+ * compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { BanyanDBClient } from '../client/index.js';
+import { ResourcesByGroup } from './types.js';
+import { log } from '../utils/logger.js';
+
+export async function loadQueryContext(
+ banyandbClient: BanyanDBClient,
+): Promise<{ groups: string[]; resourcesByGroup: ResourcesByGroup }> {
+ let groups: string[] = [];
+ try {
+ const groupsList = await banyandbClient.listGroups();
+ groups = groupsList
+ .map((groupResource) => groupResource.metadata?.name || '')
+ .filter((groupName) => groupName !== '');
+ } catch (error) {
+ log.warn(
+ 'Failed to fetch groups, continuing without group information:',
+ error instanceof Error ? error.message : String(error),
+ );
+ }
+
+ const resourcesByGroup: ResourcesByGroup = {};
+ for (const group of groups) {
+ try {
+ const [streams, measures, traces, properties, topNItems, indexRule] =
await Promise.all([
+ banyandbClient.listStreams(group).catch(() => []),
+ banyandbClient.listMeasures(group).catch(() => []),
+ banyandbClient.listTraces(group).catch(() => []),
+ banyandbClient.listProperties(group).catch(() => []),
+ banyandbClient.listTopN(group).catch(() => []),
+ banyandbClient.listIndexRule(group).catch(() => []),
+ ]);
+
+ resourcesByGroup[group] = {
+ streams: streams.map((resource) => resource.metadata?.name ||
'').filter((resourceName) => resourceName !== ''),
+ measures: measures
+ .map((resource) => resource.metadata?.name || '')
+ .filter((resourceName) => resourceName !== ''),
+ traces: traces.map((resource) => resource.metadata?.name ||
'').filter((resourceName) => resourceName !== ''),
+ properties: properties
+ .map((resource) => resource.metadata?.name || '')
+ .filter((resourceName) => resourceName !== ''),
+ topNItems: topNItems
+ .map((resource) => resource.metadata?.name || '')
+ .filter((resourceName) => resourceName !== ''),
+ indexRule: indexRule
+ .filter((resource) => !resource.noSort && resource.metadata?.name)
+ .map((resource) => resource.metadata?.name || ''),
+ };
+ } catch (error) {
+ log.warn(
+ `Failed to fetch resources for group "${group}", continuing:`,
+ error instanceof Error ? error.message : String(error),
+ );
+ resourcesByGroup[group] = { streams: [], measures: [], traces: [],
properties: [], topNItems: [], indexRule: [] };
+ }
+ }
+
+ return { groups, resourcesByGroup };
+}
diff --git a/mcp/src/query/validation.ts b/mcp/src/query/validation.ts
new file mode 100644
index 000000000..2d91811c4
--- /dev/null
+++ b/mcp/src/query/validation.ts
@@ -0,0 +1,175 @@
+/**
+ * Licensed to Apache Software Foundation (ASF) under one or more contributor
+ * license agreements. See the NOTICE file distributed with this work for
+ * additional information regarding copyright ownership. Apache Software
+ * Foundation (ASF) licenses this file to you under the Apache License,
+ * Version 2.0 (the "License"); you may not use this file except in
+ * compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+const maxDescriptionLength = 2048;
+const maxIdentifierLength = 256;
+const maxBydbQLLength = 4096;
+const identifierPattern = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/;
+const allowedResourceTypes = ['groups', 'streams', 'measures', 'traces',
'properties'] as const;
+const allowedQueryHintResourceTypes = ['stream', 'measure', 'trace',
'property'] as const;
+const disallowedQueryTokenPatterns = [/[;]/, /--/, /\/\*/, /\*\//];
+const allowedQueryPrefixPatterns = [/^\s*SELECT\b/i, /^\s*SHOW\s+TOP\b/i];
+
+export type QueryHints = {
+ description?: string;
+ BydbQL?: string;
+ resource_type?: string;
+ resource_name?: string;
+ group?: string;
+};
+
+export type ListGroupsArgs = {
+ resourceType: (typeof allowedResourceTypes)[number];
+ group?: string;
+};
+
+function containsControlCharacters(value: string): boolean {
+ for (const char of value) {
+ const charCode = char.charCodeAt(0);
+ if (
+ (charCode >= 0x00 && charCode <= 0x08) ||
+ charCode === 0x0b ||
+ charCode === 0x0c ||
+ (charCode >= 0x0e && charCode <= 0x1f) ||
+ charCode === 0x7f
+ ) {
+ return true;
+ }
+ }
+
+ return false;
+}
+
+function validateTextInput(fieldName: string, rawValue: string, maxLength:
number): string {
+ const value = rawValue.trim();
+ if (!value) {
+ throw new Error(`${fieldName} cannot be empty`);
+ }
+ if (value.length > maxLength) {
+ throw new Error(`${fieldName} exceeds the maximum length of ${maxLength}
characters`);
+ }
+ if (containsControlCharacters(value)) {
+ throw new Error(`${fieldName} contains unsupported control characters`);
+ }
+
+ return value;
+}
+
+function validateIdentifier(fieldName: string, rawValue: string): string {
+ const value = validateTextInput(fieldName, rawValue, maxIdentifierLength);
+ if (!identifierPattern.test(value)) {
+ throw new Error(`${fieldName} contains unsupported characters`);
+ }
+
+ return value;
+}
+
+function validateResourceType(rawValue: string): (typeof
allowedResourceTypes)[number] {
+ const value = validateTextInput('resource_type', rawValue, 32).toLowerCase();
+ if (!allowedResourceTypes.includes(value as (typeof
allowedResourceTypes)[number])) {
+ throw new Error('resource_type must be one of: groups, streams, measures,
traces, properties');
+ }
+
+ return value as (typeof allowedResourceTypes)[number];
+}
+
+function validateQueryHintResourceType(rawValue: string): (typeof
allowedQueryHintResourceTypes)[number] {
+ const value = validateTextInput('resource_type', rawValue, 32).toLowerCase();
+ if (!allowedQueryHintResourceTypes.includes(value as (typeof
allowedQueryHintResourceTypes)[number])) {
+ throw new Error('resource_type must be one of: stream, measure, trace,
property');
+ }
+
+ return value as (typeof allowedQueryHintResourceTypes)[number];
+}
+
+function validateBydbQL(rawValue: string): string {
+ const value = validateTextInput('BydbQL', rawValue, maxBydbQLLength);
+ if (!allowedQueryPrefixPatterns.some((pattern) => pattern.test(value))) {
+ throw new Error('BydbQL must be a read-only SELECT or SHOW TOP query');
+ }
+ for (const disallowedPattern of disallowedQueryTokenPatterns) {
+ if (disallowedPattern.test(value)) {
+ throw new Error('BydbQL contains unsupported multi-statement or comment
syntax');
+ }
+ }
+
+ return value;
+}
+
+export function normalizeQueryHints(args: unknown): QueryHints {
+ if (!args || typeof args !== 'object') {
+ return {};
+ }
+
+ const rawArgs = args as Record<string, unknown>;
+ return {
+ description: typeof rawArgs.description === 'string' ?
rawArgs.description.trim() : undefined,
+ BydbQL: typeof rawArgs.BydbQL === 'string' ? rawArgs.BydbQL.trim() :
undefined,
+ resource_type: typeof rawArgs.resource_type === 'string' ?
rawArgs.resource_type.trim() : undefined,
+ resource_name: typeof rawArgs.resource_name === 'string' ?
rawArgs.resource_name.trim() : undefined,
+ group: typeof rawArgs.group === 'string' ? rawArgs.group.trim() :
undefined,
+ };
+}
+
+export function validateListGroupsArgs(args: unknown): ListGroupsArgs {
+ if (!args || typeof args !== 'object') {
+ throw new Error('tool arguments must be an object');
+ }
+
+ const rawArgs = args as Record<string, unknown>;
+ const rawResourceType = rawArgs.resource_type;
+ if (typeof rawResourceType !== 'string') {
+ throw new Error('resource_type is required and must be one of: groups,
streams, measures, traces, properties');
+ }
+
+ const resourceType = validateResourceType(rawResourceType);
+ if (resourceType === 'groups') {
+ return { resourceType };
+ }
+
+ const rawGroup = rawArgs.group;
+ if (typeof rawGroup !== 'string') {
+ throw new Error(`group is required for listing ${resourceType}`);
+ }
+
+ return {
+ resourceType,
+ group: validateIdentifier('group', rawGroup),
+ };
+}
+
+export function validateQueryHints(queryHints: QueryHints): QueryHints {
+ const validatedHints: QueryHints = {};
+
+ if (queryHints.description) {
+ validatedHints.description = validateTextInput('description',
queryHints.description, maxDescriptionLength);
+ }
+ if (queryHints.BydbQL) {
+ validatedHints.BydbQL = validateBydbQL(queryHints.BydbQL);
+ }
+ if (queryHints.resource_type) {
+ validatedHints.resource_type =
validateQueryHintResourceType(queryHints.resource_type);
+ }
+ if (queryHints.resource_name) {
+ validatedHints.resource_name = validateIdentifier('resource_name',
queryHints.resource_name);
+ }
+ if (queryHints.group) {
+ validatedHints.group = validateIdentifier('group', queryHints.group);
+ }
+
+ return validatedHints;
+}
diff --git a/mcp/src/server/http.ts b/mcp/src/server/http.ts
new file mode 100644
index 000000000..cf1c2534c
--- /dev/null
+++ b/mcp/src/server/http.ts
@@ -0,0 +1,196 @@
+/**
+ * Licensed to Apache Software Foundation (ASF) under one or more contributor
+ * license agreements. See the NOTICE file distributed with this work for
+ * additional information regarding copyright ownership. Apache Software
+ * Foundation (ASF) licenses this file to you under the Apache License,
+ * Version 2.0 (the "License"); you may not use this file except in
+ * compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { createServer, IncomingMessage } from 'node:http';
+
+import { StreamableHTTPServerTransport } from
'@modelcontextprotocol/sdk/server/streamableHttp.js';
+import { BanyanDBClient } from '../client/index.js';
+import {
+ BANYANDB_ADDRESS,
+ MAX_ERROR_MESSAGE_LENGTH,
+ MCP_AUTH_TOKEN,
+ MCP_HOST,
+ MCP_MAX_BODY_BYTES,
+ MCP_PORT,
+ MCP_RATE_LIMIT_MAX_CLIENTS,
+ MCP_RATE_LIMIT_MAX_REQUESTS,
+ MCP_RATE_LIMIT_WINDOW_MS,
+} from '../config.js';
+import { createMcpServer, truncateText } from './mcp.js';
+import { log } from '../utils/logger.js';
+
+type RateLimitEntry = {
+ count: number;
+ windowStart: number;
+};
+
+const rateLimitByClient = new Map<string, RateLimitEntry>();
+
+function getAuthorizationToken(req: IncomingMessage): string {
+ const authHeader = req.headers.authorization;
+ if (!authHeader) {
+ return '';
+ }
+
+ const bearerPrefix = 'Bearer ';
+ return authHeader.startsWith(bearerPrefix) ?
authHeader.slice(bearerPrefix.length).trim() : '';
+}
+
+export function isLoopbackHost(host: string): boolean {
+ return host === '127.0.0.1' || host === 'localhost' || host === '::1';
+}
+
+function getClientIdentifier(req: IncomingMessage): string {
+ const forwardedFor = req.headers['x-forwarded-for'];
+ if (typeof forwardedFor === 'string' && forwardedFor.trim()) {
+ return forwardedFor.split(',')[0].trim();
+ }
+
+ return req.socket.remoteAddress || 'unknown';
+}
+
+function pruneExpiredRateLimitEntries(now: number): void {
+ for (const [clientId, currentEntry] of rateLimitByClient.entries()) {
+ if (now - currentEntry.windowStart >= MCP_RATE_LIMIT_WINDOW_MS) {
+ rateLimitByClient.delete(clientId);
+ }
+ }
+}
+
+function evictOldestRateLimitEntry(): void {
+ const oldestEntry = rateLimitByClient.entries().next().value;
+ if (oldestEntry) {
+ const [clientId] = oldestEntry;
+ rateLimitByClient.delete(clientId);
+ }
+}
+
+function isRateLimited(clientId: string, now: number): boolean {
+ pruneExpiredRateLimitEntries(now);
+
+ const currentEntry = rateLimitByClient.get(clientId);
+ if (!currentEntry || now - currentEntry.windowStart >=
MCP_RATE_LIMIT_WINDOW_MS) {
+ if (!currentEntry && rateLimitByClient.size >= MCP_RATE_LIMIT_MAX_CLIENTS)
{
+ evictOldestRateLimitEntry();
+ }
+ rateLimitByClient.set(clientId, { count: 1, windowStart: now });
+ return false;
+ }
+
+ if (currentEntry.count >= MCP_RATE_LIMIT_MAX_REQUESTS) {
+ return true;
+ }
+
+ rateLimitByClient.delete(clientId);
+ currentEntry.count += 1;
+ rateLimitByClient.set(clientId, currentEntry);
+ return false;
+}
+
+export function startHttpServer(banyandbClient: BanyanDBClient): void {
+ const httpServer = createServer((req, res) => {
+ const { pathname } = new URL(req.url ?? '', 'http://localhost');
+ if (pathname !== '/mcp') {
+ res.writeHead(404, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'Not found' }));
+ return;
+ }
+
+ if (req.method !== 'POST') {
+ res.writeHead(405, {
+ 'Content-Type': 'application/json',
+ Allow: 'POST',
+ });
+ res.end(JSON.stringify({ error: 'Method not allowed' }));
+ return;
+ }
+
+ if (MCP_AUTH_TOKEN && getAuthorizationToken(req) !== MCP_AUTH_TOKEN) {
+ res.writeHead(401, {
+ 'Content-Type': 'application/json',
+ 'WWW-Authenticate': 'Bearer',
+ });
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
+ return;
+ }
+
+ const clientId = getClientIdentifier(req);
+ if (isRateLimited(clientId, Date.now())) {
+ res.writeHead(429, {
+ 'Content-Type': 'application/json',
+ 'Retry-After': `${Math.ceil(MCP_RATE_LIMIT_WINDOW_MS / 1000)}`,
+ });
+ res.end(JSON.stringify({ error: 'Too many requests' }));
+ return;
+ }
+
+ const bodyChunks: Buffer[] = [];
+ let receivedBytes = 0;
+ let requestTooLarge = false;
+ req.on('data', (chunk: Buffer) => {
+ receivedBytes += chunk.length;
+ if (receivedBytes > MCP_MAX_BODY_BYTES) {
+ requestTooLarge = true;
+ res.writeHead(413, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: `Request body too large. Limit is
${MCP_MAX_BODY_BYTES} bytes.` }));
+ req.destroy();
+ return;
+ }
+
+ bodyChunks.push(chunk);
+ });
+ req.on('end', async () => {
+ if (requestTooLarge) {
+ return;
+ }
+
+ let parsedBody: unknown;
+ if (receivedBytes > 0) {
+ try {
+ parsedBody = JSON.parse(Buffer.concat(bodyChunks,
receivedBytes).toString('utf-8'));
+ } catch {
+ res.writeHead(400, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'Invalid JSON in request body' }));
+ return;
+ }
+ }
+
+ try {
+ const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined });
+ const mcpServer = createMcpServer(banyandbClient);
+ await mcpServer.connect(transport);
+ await transport.handleRequest(req, res, parsedBody);
+ } catch (error) {
+ if (!res.headersSent) {
+ res.writeHead(500, { 'Content-Type': 'application/json' });
+ res.end(
+ JSON.stringify({
+ error: truncateText(error instanceof Error ? error.message :
String(error), MAX_ERROR_MESSAGE_LENGTH),
+ }),
+ );
+ }
+ }
+ });
+ });
+
+ httpServer.listen(MCP_PORT, MCP_HOST, () => {
+ log.info(`BanyanDB MCP HTTP server listening on
${MCP_HOST}:${MCP_PORT}/mcp`);
+ log.info(`Connecting to BanyanDB at ${BANYANDB_ADDRESS}`);
+ log.info(`HTTP auth ${MCP_AUTH_TOKEN ? 'enabled' : 'disabled'}`);
+ log.info(`HTTP rate limit ${MCP_RATE_LIMIT_MAX_REQUESTS} requests per
${MCP_RATE_LIMIT_WINDOW_MS}ms`);
+ });
+}
diff --git a/mcp/src/server/mcp.ts b/mcp/src/server/mcp.ts
new file mode 100644
index 000000000..1f237061b
--- /dev/null
+++ b/mcp/src/server/mcp.ts
@@ -0,0 +1,341 @@
+/**
+ * Licensed to Apache Software Foundation (ASF) under one or more contributor
+ * license agreements. See the NOTICE file distributed with this work for
+ * additional information regarding copyright ownership. Apache Software
+ * Foundation (ASF) licenses this file to you under the Apache License,
+ * Version 2.0 (the "License"); you may not use this file except in
+ * compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { z } from 'zod';
+
+import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
+import { CallToolRequestSchema, ListToolsRequestSchema } from
'@modelcontextprotocol/sdk/types.js';
+import { BanyanDBClient, ResourceMetadata } from '../client/index.js';
+import { MAX_TOOL_RESPONSE_LENGTH } from '../config.js';
+import { loadQueryContext } from '../query/context.js';
+import { generateBydbQL } from '../query/llm-prompt.js';
+import { normalizeQueryHints, validateListGroupsArgs, validateQueryHints }
from '../query/validation.js';
+
+export function truncateText(text: string, maxLength: number): string {
+ if (text.length <= maxLength) {
+ return text;
+ }
+
+ return `${text.slice(0, maxLength)}\n... [truncated ${text.length -
maxLength} characters]`;
+}
+
+const generateBydbQLPromptSchema = {
+ description: z
+ .string()
+ .describe(
+ "Natural language description of the query (e.g., 'list the last 30
minutes service_cpm_minute', 'show the last 30 zipkin spans order by time')",
+ ),
+ resource_type: z
+ .enum(['stream', 'measure', 'trace', 'property'])
+ .optional()
+ .describe('Optional resource type hint: stream, measure, trace, or
property'),
+ resource_name: z.string().optional().describe('Optional resource name hint
(stream/measure/trace/property name)'),
+ group: z.string().optional().describe('Optional group hint, for example the
properties group'),
+} as const;
+
+function buildListToolsResponse() {
+ return {
+ tools: [
+ {
+ name: 'list_groups_schemas',
+ description:
+ 'List available resources in BanyanDB (groups, streams, measures,
traces, properties). Use this to discover what resources exist before
querying.',
+ inputSchema: {
+ type: 'object',
+ properties: {
+ resource_type: {
+ type: 'string',
+ description: 'Type of resource to list: groups, streams,
measures, traces, or properties',
+ enum: ['groups', 'streams', 'measures', 'traces', 'properties'],
+ },
+ group: {
+ type: 'string',
+ description: 'Group name (required for streams, measures,
traces, and properties)',
+ },
+ },
+ required: ['resource_type'],
+ },
+ },
+ {
+ name: 'list_resources_bydbql',
+ description: 'Fetch streams, measures, traces, or properties from
BanyanDB using a BydbQL query.',
+ inputSchema: {
+ type: 'object',
+ properties: {
+ BydbQL: {
+ type: 'string',
+ description: 'BydbQL query to execute against BanyanDB.',
+ },
+ resource_type: {
+ type: 'string',
+ description: 'Optional resource type hint: stream, measure,
trace, or property',
+ enum: ['stream', 'measure', 'trace', 'property'],
+ },
+ resource_name: {
+ type: 'string',
+ description: 'Optional resource name hint
(stream/measure/trace/property name)',
+ },
+ group: {
+ type: 'string',
+ description: 'Optional group hint, for example the properties
group',
+ },
+ },
+ required: ['BydbQL'],
+ },
+ },
+ {
+ name: 'get_generate_bydbql_prompt',
+ description:
+ 'Return the full prompt text used by generate_BydbQL, including live
BanyanDB schema hints. This lets external projects retrieve the prompt text
directly.',
+ inputSchema: {
+ type: 'object',
+ properties: {
+ description: {
+ type: 'string',
+ description:
+ "Natural language description of the query (e.g., 'list the
last 30 minutes service_cpm_minute', 'show the last 30 zipkin spans order by
time')",
+ },
+ resource_type: {
+ type: 'string',
+ description: 'Optional resource type hint: stream, measure,
trace, or property',
+ enum: ['stream', 'measure', 'trace', 'property'],
+ },
+ resource_name: {
+ type: 'string',
+ description: 'Optional resource name hint
(stream/measure/trace/property name)',
+ },
+ group: {
+ type: 'string',
+ description: 'Optional group hint, for example the properties
group',
+ },
+ },
+ required: ['description'],
+ },
+ },
+ ],
+ };
+}
+
+async function handleListGroupsSchemas(banyandbClient: BanyanDBClient, args:
unknown) {
+ const { resourceType, group } = validateListGroupsArgs(args);
+
+ let result: string;
+
+ if (resourceType === 'groups') {
+ const groups = await banyandbClient.listGroups();
+ const groupNames = groups
+ .map((groupResource) => groupResource.metadata?.name || '')
+ .filter((groupName) => groupName !== '');
+ result = `Available Groups
(${groupNames.length}):\n${groupNames.join('\n')}`;
+ if (groupNames.length === 0) {
+ result += '\n\nNo groups found. BanyanDB may be empty or not
configured.';
+ }
+ } else {
+ if (!group) {
+ throw new Error(`group is required for listing ${resourceType}`);
+ }
+
+ const resourceFetchers: Record<string, () => Promise<ResourceMetadata[]>>
= {
+ streams: () => banyandbClient.listStreams(group),
+ measures: () => banyandbClient.listMeasures(group),
+ traces: () => banyandbClient.listTraces(group),
+ properties: () => banyandbClient.listProperties(group),
+ };
+
+ const fetcher = resourceFetchers[resourceType];
+ if (!fetcher) {
+ throw new Error(
+ `Invalid resource_type "${resourceType}". Must be one of: groups,
streams, measures, traces, properties`,
+ );
+ }
+
+ const resources = await fetcher();
+ const resourceNames = resources
+ .map((resource) => resource.metadata?.name || '')
+ .filter((resourceName) => resourceName !== '');
+ const resourceLabel = resourceType.charAt(0).toUpperCase() +
resourceType.slice(1);
+
+ result = `Available ${resourceLabel} in group "${group}"
(${resourceNames.length}):\n${resourceNames.join('\n')}`;
+ if (resourceNames.length === 0) {
+ result += `\n\nNo ${resourceType} found in group "${group}".`;
+ }
+ }
+
+ return { content: [{ type: 'text', text: result }] };
+}
+
+async function handleListResourcesBydbql(banyandbClient: BanyanDBClient, args:
unknown) {
+ const queryHints = validateQueryHints(normalizeQueryHints(args));
+ if (!queryHints.BydbQL) {
+ throw new Error('BydbQL is required');
+ }
+
+ try {
+ const result = await banyandbClient.query(queryHints.BydbQL);
+ const debugParts: string[] = [];
+
+ if (queryHints.resource_type) debugParts.push(`Resource Type:
${queryHints.resource_type}`);
+ if (queryHints.resource_name) debugParts.push(`Resource Name:
${queryHints.resource_name}`);
+ if (queryHints.group) debugParts.push(`Group: ${queryHints.group}`);
+
+ const debugInfo = debugParts.length > 0 ? `\n\n=== Debug Information
===\n${debugParts.join('\n')}` : '';
+ const text = truncateText(
+ `=== Query Result ===\n\n${result}\n\n=== BydbQL Query
===\n${queryHints.BydbQL}${debugInfo}`,
+ MAX_TOOL_RESPONSE_LENGTH,
+ );
+
+ return { content: [{ type: 'text', text }] };
+ } catch (error) {
+ if (error instanceof Error) {
+ if (error.message.includes('timeout') ||
error.message.includes('Timeout')) {
+ return {
+ content: [
+ {
+ type: 'text',
+ text:
+ `Query timeout: ${error.message}\n\n` +
+ `Possible causes:\n` +
+ `- BanyanDB is not running or not accessible\n` +
+ `- Network connectivity issues\n` +
+ `- BanyanDB is overloaded or slow\n\n` +
+ `Try:\n` +
+ `1. Verify BanyanDB is running: curl
http://localhost:17913/api/healthz\n` +
+ `2. Check network connectivity\n` +
+ `3. Use list_groups_schemas to verify BanyanDB is accessible`,
+ },
+ ],
+ };
+ }
+ if (
+ error.message.includes('not found') ||
+ error.message.includes('does not exist') ||
+ error.message.includes('Empty response')
+ ) {
+ return {
+ content: [
+ {
+ type: 'text',
+ text:
+ `Query failed: ${error.message}\n\n` +
+ `Tip: Use the list_groups_schemas tool to discover available
resources:\n` +
+ `- First list groups: list_groups_schemas with
resource_type="groups"\n` +
+ `- Then list streams, measures, traces, or properties for a
target group\n` +
+ `- Then query using a natural language description and
optional resource_type, resource_name, or group hints.`,
+ },
+ ],
+ };
+ }
+ throw error;
+ }
+ throw new Error(`Query execution failed: ${String(error)}`);
+ }
+}
+
+async function handleGetGenerateBydbqlPrompt(banyandbClient: BanyanDBClient,
args: unknown) {
+ const queryHints = validateQueryHints(normalizeQueryHints(args));
+ if (!queryHints.description) {
+ throw new Error('description is required');
+ }
+
+ const { groups, resourcesByGroup } = await loadQueryContext(banyandbClient);
+ const prompt = generateBydbQL(queryHints.description, queryHints, groups,
resourcesByGroup);
+
+ return { content: [{ type: 'text', text: prompt }] };
+}
+
+export function createMcpServer(banyandbClient: BanyanDBClient): McpServer {
+ const server = new McpServer(
+ {
+ name: 'banyandb-mcp',
+ version: '1.0.0',
+ },
+ {
+ capabilities: {
+ tools: {},
+ },
+ },
+ );
+
+ const registerPrompt = server.registerPrompt.bind(server) as unknown as (
+ name: string,
+ config: { description: string; argsSchema: unknown },
+ cb: (args: { description: string; resource_type?: string; resource_name?:
string; group?: string }) => Promise<{
+ messages: Array<{
+ role: 'user';
+ content: {
+ type: 'text';
+ text: string;
+ };
+ }>;
+ }>,
+ ) => unknown;
+
+ registerPrompt(
+ 'generate_BydbQL',
+ {
+ description:
+ 'Generate the prompt/context needed to derive correct BydbQL from
natural language and BanyanDB schema hints. Use list_groups_schemas first to
discover available resources.',
+ argsSchema: generateBydbQLPromptSchema,
+ },
+ async (args) => {
+ const validatedHints = validateQueryHints({
+ description: args.description,
+ resource_type: args.resource_type,
+ resource_name: args.resource_name,
+ group: args.group,
+ });
+ const description = validatedHints.description;
+ if (!description) {
+ throw new Error('description is required');
+ }
+
+ const { groups, resourcesByGroup } = await
loadQueryContext(banyandbClient);
+ const prompt = generateBydbQL(description, validatedHints, groups,
resourcesByGroup);
+
+ return {
+ messages: [
+ {
+ role: 'user',
+ content: {
+ type: 'text',
+ text: prompt,
+ },
+ },
+ ],
+ };
+ },
+ );
+
+ server.server.setRequestHandler(ListToolsRequestSchema, async () =>
buildListToolsResponse());
+ server.server.setRequestHandler(CallToolRequestSchema, async (request) => {
+ const { name, arguments: args } = request.params;
+
+ if (name === 'list_groups_schemas') {
+ return handleListGroupsSchemas(banyandbClient, args);
+ }
+ if (name === 'list_resources_bydbql') {
+ return handleListResourcesBydbql(banyandbClient, args);
+ }
+ if (name === 'get_generate_bydbql_prompt') {
+ return handleGetGenerateBydbqlPrompt(banyandbClient, args);
+ }
+
+ throw new Error(`Unknown tool: ${name}`);
+ });
+
+ return server;
+}