This is an automated email from the ASF dual-hosted git repository.
qiuxiafan 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 43d29392 feat(mcp): support query description without explicit groups
and conditions (#870)
43d29392 is described below
commit 43d293928963d623e79f1de6abaa1e5d64aab643
Author: Fine0830 <[email protected]>
AuthorDate: Fri Nov 28 14:39:55 2025 +0800
feat(mcp): support query description without explicit groups and conditions
(#870)
---
.github/workflows/ci.yml | 2 +-
CHANGES.md | 1 +
docs/operation/mcp/inspector.md | 63 +++-
docs/operation/mcp/setup.md | 8 +-
mcp/.licenserc.yaml | 2 +
mcp/src/{ => client}/banyandb-client.ts | 409 +++++++++------------
mcp/src/client/types.ts | 110 ++++++
mcp/src/index.ts | 69 +++-
mcp/src/llm-prompt.ts | 199 ----------
mcp/src/query/llm-prompt.ts | 272 ++++++++++++++
.../pattern-matcher.ts} | 344 +++--------------
mcp/src/query/query-generator.ts | 299 +++++++++++++++
mcp/src/query/types.ts | 44 +++
mcp/src/utils/http.ts | 68 ++++
mcp/src/{ => utils}/logger.ts | 0
15 files changed, 1149 insertions(+), 741 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 0427256d..829d7d95 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -66,7 +66,7 @@ jobs:
run: make license-check
- name: Check requirements
run: make check-req
- - name: Build binaries and UI dist
+ - name: Build binaries, UI dist and MCP dist
run: make build
- name: Lint
run: make lint
diff --git a/CHANGES.md b/CHANGES.md
index 0098c88a..9ce68fdd 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -7,6 +7,7 @@ Release Notes.
### Features
- Remove Bloom filter for dictionary-encoded tags.
+- Implement BanyanDB MCP.
### Bug Fixes
diff --git a/docs/operation/mcp/inspector.md b/docs/operation/mcp/inspector.md
index d80bf689..7b91e47b 100644
--- a/docs/operation/mcp/inspector.md
+++ b/docs/operation/mcp/inspector.md
@@ -73,7 +73,7 @@ The MCP server translates natural language descriptions into
BydbQL queries and
**Natural Language Description:**
```
-list the last 30 minutes service_cpm_minute in metricsMinute
+list the last 30 minutes service_cpm_minute
```
**Generated BydbQL Query:**
@@ -120,20 +120,20 @@ Measure Query Result:
SELECT * FROM MEASURE service_cpm_minute IN metricsMinute TIME > '-30m'
=== Debug Information ===
-Description: list the last day service_cpm_minute in metricsMinute
+Description: list the last day service_cpm_minute
Resource Type: measure
Resource Name: service_cpm_minute
Group: metricsMinute
=== Explanations ===
-This query selects all data from the resource 'service_cpm_minute' of type
MEASURE in the group 'metricsMinute' for the last day, as specified in the
description.
+This query selects all data from the resource 'service_cpm_minute' of type
MEASURE in the group 'metricsMinute' for the last 30 minutes, as specified in
the description.
```
### Example 2: Querying a Stream
**Natural Language Description:**
```
-list log data in last day, the group is recordsLog
+list log data in last day
```
**Generated BydbQL Query:**
@@ -178,7 +178,7 @@ Stream Query Result:
SELECT * FROM STREAM log IN recordsLog TIME > '-1d'
=== Debug Information ===
-Description: list log data in last day, the group is recordsLog
+Description: list log data in the last day
Resource Type: stream
Resource Name: log
Group: recordsLog
@@ -191,7 +191,7 @@ This query retrieves all log data from the 'recordsLog'
group for the last day.
**Natural Language Description:**
```
-list the last 3 zipkin_span order by timestamp_millis, the group is zipkinTrace
+list the last 3 zipkin_span order by time
```
**Generated BydbQL Query:**
@@ -244,7 +244,7 @@ Trace Query Result:
SELECT * FROM TRACE zipkin_span IN zipkinTrace ORDER BY timestamp_millis DESC
LIMIT 3
=== Debug Information ===
-Description: list the last 3 zipkin_span order by timestamp_millis, the group
is zipkinTrace
+Description: list the last 3 zipkin_span order by time
Resource Type: trace
Resource Name: zipkin_span
Group: zipkinTrace
@@ -257,7 +257,7 @@ This query retrieves the last 3 entries of zipkin_span from
the zipkinTrace grou
**Natural Language Description:**
```
-Show TOP3 MEASURE endpoint_2xx in metricsMinute from last 30 minutes,
AGGREGATE BY MAX and ORDER BY DESC
+Show TOP3 MEASURE endpoint_2xx from last 30 minutes, AGGREGATE BY MAX and
ORDER BY DESC
```
**Generated BydbQL Query:**
@@ -302,7 +302,7 @@ TopN Query Result:
SHOW TOP 3 FROM MEASURE endpoint_2xx IN metricsMinute TIME > '-30m' AGGREGATE
BY MAX ORDER BY DESC
=== Debug Information ===
-Description: Show TOP3 MEASURE endpoint_2xx in metricsMinute from last 30
minutes, AGGREGATE BY MAX and ORDER BY DESC
+Description: Show TOP3 MEASURE endpoint_2xx from last 30 minutes, AGGREGATE BY
MAX and ORDER BY DESC
Resource Type: measure
Resource Name: endpoint_2xx
Group: metricsMinute
@@ -311,6 +311,51 @@ Group: metricsMinute
This query fetches the top 3 maximum values of the measure 'endpoint_2xx' from
the group 'metricsMinute' over the last 30 minutes. The values were explicitly
provided for resource name, group name, and aggregate function, while the time
condition is derived from the description.
```
+## Resource Lookup Behavior
+
+The MCP server uses intelligent resource lookup when generating queries from
natural language descriptions. Understanding this behavior helps you write more
effective queries.
+
+### Resource Name Lookup Across Groups
+
+When the same resource name exists in multiple groups, the LLM will use the
**first group it can look up** from the available resource mappings. The lookup
process follows this order:
+
+1. **Exact Match**: The LLM first attempts to find an exact match for the
resource name
+2. **First Available Group**: If the resource name appears in multiple groups,
the LLM will use the first group it encounters during lookup
+3. **Similarity Matching**: If an exact match cannot be found, the LLM will
match the most similar resource name from the available resources
+
+### Example Scenarios
+
+**Scenario 1: Same Resource Name in Multiple Groups**
+
+If you have a resource named `service_cpm` in both `metricsMinute` and
`metricsHour` groups:
+
+```
+Description: "list service_cpm from last hour"
+```
+
+The LLM will use the first group it finds in the resource mapping. To ensure a
specific group is used, explicitly mention it:
+
+```
+Description: "list service_cpm in metricsHour from last hour"
+```
+
+**Scenario 2: Similarity Matching**
+
+If you request a resource that doesn't exist exactly, the LLM will find the
most similar match:
+
+```
+Description: "list service_cpm from last hour"
+Available resources: service_cpm_minute, service_cpm_hour
+```
+
+The LLM will match `service_cpm_minute` or `service_cpm_hour` based on
similarity, preferring the closest match.
+
+### Best Practices
+
+- **Be Explicit**: When you know the exact group name, include it in your
description (e.g., "in metricsMinute")
+- **Use Specific Names**: Use the exact resource names from your BanyanDB
schema when possible
+- **Check Available Resources**: Use the `list_resources_bydbql` tool to see
available resources and their groups before querying
+
## Troubleshooting
**Inspector UI not opening:**
diff --git a/docs/operation/mcp/setup.md b/docs/operation/mcp/setup.md
index 7f56b386..933e6929 100644
--- a/docs/operation/mcp/setup.md
+++ b/docs/operation/mcp/setup.md
@@ -52,7 +52,7 @@ docker run -d \
-e BANYANDB_ADDRESS=banyandb:17900 \
-e LLM_API_KEY=sk-your-key-here \
-e LLM_BASE_URL=your-llm-base-url \
- apache/skywalking-banyandb-mcp:latest
+ ghcr.io/apache/skywalking-banyandb-mcp:{COMMIT_ID}
```
### 3. Configure MCP Client for Docker
@@ -72,7 +72,7 @@ When using Docker, configure your MCP client to connect to
the container:
"-e", "LLM_API_KEY=sk-your-key-here",
"-e", "LLM_BASE_URL=your-llm-base-url",
"--network", "host",
- "apache/skywalking-banyandb-mcp:latest"
+ "ghcr.io/apache/skywalking-banyandb-mcp:{COMMIT_ID}"
]
}
}
@@ -86,7 +86,7 @@ You can also use Docker Compose to run both BanyanDB and the
MCP server together
```yaml
services:
banyandb:
- image: ghcr.io/apache/skywalking-banyandb:latest
+ image: ghcr.io/apache/skywalking-banyandb:{COMMIT_ID} #
apache/skywalking-banyandb:{COMMIT_ID}
container_name: banyandb
command: standalone
ports:
@@ -96,7 +96,7 @@ services:
- ./banyandb-data:/data
mcp:
- image: apache/skywalking-banyandb-mcp:latest
+ image: ghcr.io/apache/skywalking-banyandb-mcp:{COMMIT_ID} #
apache/skywalking-banyandb-mcp:{COMMIT_ID}
container_name: banyandb-mcp
environment:
- BANYANDB_ADDRESS=banyandb:17900
diff --git a/mcp/.licenserc.yaml b/mcp/.licenserc.yaml
index 753404f0..5d5f652d 100644
--- a/mcp/.licenserc.yaml
+++ b/mcp/.licenserc.yaml
@@ -68,6 +68,8 @@ header: # `header` section is configurations for source codes
license header.
- '*.md'
- '.prettierignore'
- '.dockerignore'
+ - 'banyandb-data'
+ - '.vscode'
comment: on-failure # on what condition license-eye will comment on the pull
request, `on-failure`, `always`, `never`.
diff --git a/mcp/src/banyandb-client.ts b/mcp/src/client/banyandb-client.ts
similarity index 59%
rename from mcp/src/banyandb-client.ts
rename to mcp/src/client/banyandb-client.ts
index 911b81a5..ec3768b6 100644
--- a/mcp/src/banyandb-client.ts
+++ b/mcp/src/client/banyandb-client.ts
@@ -17,96 +17,8 @@
* under the License.
*/
-interface QueryRequest {
- query: string;
-}
-
-interface TagValue {
- str?: { value: string };
- int?: { value: number };
- float?: { value: number };
- binaryData?: unknown;
-}
-
-interface Tag {
- key: string;
- value: TagValue;
-}
-
-interface TagFamily {
- tags?: Tag[];
-}
-
-interface FieldValue {
- int?: { value: number };
- float?: { value: number };
- str?: { value: string };
- binaryData?: unknown;
-}
-
-interface Field {
- name: string;
- value: FieldValue;
-}
-
-interface DataPoint {
- timestamp?: string | number;
- sid?: string;
- version?: string | number;
- tagFamilies?: TagFamily[];
- fields?: Field[];
-}
-
-interface StreamResult {
- elements?: unknown[];
-}
-
-interface MeasureResult {
- dataPoints?: DataPoint[];
- data_points?: DataPoint[];
-}
-
-interface TraceResult {
- elements?: unknown[];
-}
-
-interface PropertyResult {
- items?: unknown[];
-}
-
-interface TopNResult {
- lists?: unknown[];
-}
-
-interface QueryResponse {
- // Response can be either wrapped in result or direct
- result?: {
- streamResult?: StreamResult;
- measureResult?: MeasureResult;
- traceResult?: TraceResult;
- propertyResult?: PropertyResult;
- topnResult?: TopNResult;
- };
- // Or directly at top level
- streamResult?: StreamResult;
- measureResult?: MeasureResult;
- traceResult?: TraceResult;
- propertyResult?: PropertyResult;
- topnResult?: TopNResult;
-}
-
-interface Group {
- metadata?: {
- name?: string;
- };
-}
-
-export interface ResourceMetadata {
- metadata?: {
- name?: string;
- group?: string;
- };
-}
+import type { QueryRequest, QueryResponse, Group, ResourceMetadata } from
'./types.js';
+import { httpFetch } from '../utils/http.js';
/**
* BanyanDBClient wraps the BanyanDB HTTP client for executing queries.
@@ -145,51 +57,24 @@ export class BanyanDBClient {
const queryDebugInfo = `Query: "${bydbqlQuery}"\nURL: ${url}`;
try {
- const controller = new AbortController();
- const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
-
- const response = await fetch(url, {
+ const data = (await httpFetch({
+ url,
method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify(request),
- signal: controller.signal,
- });
-
- clearTimeout(timeoutId);
+ json: request,
+ })) as QueryResponse | { errors: Response };
- // Read response text once
- const responseText = await response.text();
-
- if (!response.ok) {
+ // Check if httpFetch returned an error
+ if (data && typeof data === 'object' && 'errors' in data) {
+ const errorResponse = (data as { errors: Response }).errors;
+ const errorText = await errorResponse.text().catch(() =>
errorResponse.statusText);
throw new Error(
- `Query execution failed: ${response.status}
${response.statusText}\n\n${queryDebugInfo}\n\nResponse: ${responseText}`,
+ `Query execution failed: ${errorResponse.status}
${errorResponse.statusText}\n\n${queryDebugInfo}\n\nResponse: ${errorText}`,
);
}
// Check if response has content
- if (!responseText || responseText.trim().length === 0) {
- throw new Error(
- `Empty response body from BanyanDB\n\n${queryDebugInfo}\n\nHTTP
Status: ${response.status} ${response.statusText}`,
- );
- }
-
- let data: QueryResponse;
- try {
- data = JSON.parse(responseText) as QueryResponse;
- } catch (parseError) {
- throw new Error(
- `Invalid JSON response from BanyanDB: ${parseError instanceof Error
? parseError.message : String(parseError)}\n\n${queryDebugInfo}\n\nResponse
text: ${responseText.substring(0, 500)}`,
- );
- }
-
- const responseDebugInfo = `Raw response: ${JSON.stringify(data, null,
2)}`;
-
if (!data) {
- throw new Error(
- `Empty response from BanyanDB: response body is null or
undefined\n\n${queryDebugInfo}\n\n${responseDebugInfo}`,
- );
+ throw new Error(`Empty response body from
BanyanDB\n\n${queryDebugInfo}`);
}
// Check for result types both at top level and inside result wrapper
@@ -226,7 +111,7 @@ export class BanyanDBClient {
} catch (error) {
if (error instanceof Error) {
// Check if it's a timeout error
- if (error.name === 'AbortError' || error.message.includes('aborted')) {
+ if (error.name === 'AbortError' || error.message.includes('aborted')
|| error.message.includes('timeout')) {
throw new Error(
`Query timeout after ${timeoutMs}ms. ` +
`BanyanDB may be slow or unresponsive. ` +
@@ -353,29 +238,23 @@ export class BanyanDBClient {
const url = `${this.baseUrl}/v1/group/schema/lists`;
try {
- const controller = new AbortController();
- const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
-
- const response = await fetch(url, {
+ const data = (await httpFetch({
+ url,
method: 'GET',
- headers: {
- 'Content-Type': 'application/json',
- },
- signal: controller.signal,
- });
-
- clearTimeout(timeoutId);
-
- if (!response.ok) {
- const errorText = await response.text();
- throw new Error(`Failed to list groups: ${response.status}
${response.statusText} - ${errorText}`);
+ json: null,
+ })) as { group?: Group[] } | { errors: Response };
+
+ // Check if httpFetch returned an error
+ if (data && typeof data === 'object' && 'errors' in data) {
+ const errorResponse = (data as { errors: Response }).errors;
+ const errorText = await errorResponse.text().catch(() =>
errorResponse.statusText);
+ throw new Error(`Failed to list groups: ${errorResponse.status}
${errorResponse.statusText} - ${errorText}`);
}
- const data = (await response.json()) as { group?: Group[] };
- return data.group || [];
+ return (data as { group?: Group[] }).group || [];
} catch (error) {
if (error instanceof Error) {
- if (error.name === 'AbortError' || error.message.includes('aborted')) {
+ if (error.name === 'AbortError' || error.message.includes('aborted')
|| error.message.includes('timeout')) {
throw new Error(
`List groups timeout after ${timeoutMs}ms. ` +
`BanyanDB may be slow or unresponsive. ` +
@@ -401,30 +280,24 @@ export class BanyanDBClient {
/**
* List streams in a group.
*/
- async listStreams(group: string, timeoutMs: number = 30000):
Promise<ResourceMetadata[]> {
+ async listStreams(group: string): Promise<ResourceMetadata[]> {
const url =
`${this.baseUrl}/v1/stream/schema/lists/${encodeURIComponent(group)}`;
try {
- const controller = new AbortController();
- const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
-
- const response = await fetch(url, {
+ const data = (await httpFetch({
+ url,
method: 'GET',
- headers: {
- 'Content-Type': 'application/json',
- },
- signal: controller.signal,
- });
-
- clearTimeout(timeoutId);
-
- if (!response.ok) {
- const errorText = await response.text();
- throw new Error(`Failed to list streams: ${response.status}
${response.statusText} - ${errorText}`);
+ json: null,
+ })) as { stream?: ResourceMetadata[] } | { errors: Response };
+
+ // Check if httpFetch returned an error
+ if (data && typeof data === 'object' && 'errors' in data) {
+ const errorResponse = (data as { errors: Response }).errors;
+ const errorText = await errorResponse.text().catch(() =>
errorResponse.statusText);
+ throw new Error(`Failed to list streams: ${errorResponse.status}
${errorResponse.statusText} - ${errorText}`);
}
- const data = (await response.json()) as { stream?: ResourceMetadata[] };
- return data.stream || [];
+ return (data as { stream?: ResourceMetadata[] }).stream || [];
} catch (error) {
if (error instanceof Error) {
throw error;
@@ -436,30 +309,24 @@ export class BanyanDBClient {
/**
* List measures in a group.
*/
- async listMeasures(group: string, timeoutMs: number = 30000):
Promise<ResourceMetadata[]> {
+ async listMeasures(group: string): Promise<ResourceMetadata[]> {
const url =
`${this.baseUrl}/v1/measure/schema/lists/${encodeURIComponent(group)}`;
try {
- const controller = new AbortController();
- const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
-
- const response = await fetch(url, {
+ const data = (await httpFetch({
+ url,
method: 'GET',
- headers: {
- 'Content-Type': 'application/json',
- },
- signal: controller.signal,
- });
-
- clearTimeout(timeoutId);
-
- if (!response.ok) {
- const errorText = await response.text();
- throw new Error(`Failed to list measures: ${response.status}
${response.statusText} - ${errorText}`);
+ json: null,
+ })) as { measure?: ResourceMetadata[] } | { errors: Response };
+
+ // Check if httpFetch returned an error
+ if (data && typeof data === 'object' && 'errors' in data) {
+ const errorResponse = (data as { errors: Response }).errors;
+ const errorText = await errorResponse.text().catch(() =>
errorResponse.statusText);
+ throw new Error(`Failed to list measures: ${errorResponse.status}
${errorResponse.statusText} - ${errorText}`);
}
- const data = (await response.json()) as { measure?: ResourceMetadata[] };
- return data.measure || [];
+ return (data as { measure?: ResourceMetadata[] }).measure || [];
} catch (error) {
if (error instanceof Error) {
throw error;
@@ -471,30 +338,24 @@ export class BanyanDBClient {
/**
* List traces in a group.
*/
- async listTraces(group: string, timeoutMs: number = 30000):
Promise<ResourceMetadata[]> {
+ async listTraces(group: string): Promise<ResourceMetadata[]> {
const url =
`${this.baseUrl}/v1/trace/schema/lists/${encodeURIComponent(group)}`;
try {
- const controller = new AbortController();
- const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
-
- const response = await fetch(url, {
+ const data = (await httpFetch({
+ url,
method: 'GET',
- headers: {
- 'Content-Type': 'application/json',
- },
- signal: controller.signal,
- });
-
- clearTimeout(timeoutId);
-
- if (!response.ok) {
- const errorText = await response.text();
- throw new Error(`Failed to list traces: ${response.status}
${response.statusText} - ${errorText}`);
+ json: null,
+ })) as { trace?: ResourceMetadata[] } | { errors: Response };
+
+ // Check if httpFetch returned an error
+ if (data && typeof data === 'object' && 'errors' in data) {
+ const errorResponse = (data as { errors: Response }).errors;
+ const errorText = await errorResponse.text().catch(() =>
errorResponse.statusText);
+ throw new Error(`Failed to list traces: ${errorResponse.status}
${errorResponse.statusText} - ${errorText}`);
}
- const data = (await response.json()) as { trace?: ResourceMetadata[] };
- return data.trace || [];
+ return (data as { trace?: ResourceMetadata[] }).trace || [];
} catch (error) {
if (error instanceof Error) {
throw error;
@@ -506,37 +367,126 @@ export class BanyanDBClient {
/**
* List properties in a group.
*/
- async listProperties(group: string, timeoutMs: number = 30000):
Promise<ResourceMetadata[]> {
+ async listProperties(group: string): Promise<ResourceMetadata[]> {
const url =
`${this.baseUrl}/v1/property/schema/lists/${encodeURIComponent(group)}`;
try {
- const controller = new AbortController();
- const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
+ const data = (await httpFetch({
+ url,
+ method: 'GET',
+ json: null,
+ })) as { properties?: ResourceMetadata[] } | { errors: Response };
+
+ // Check if httpFetch returned an error
+ if (data && typeof data === 'object' && 'errors' in data) {
+ const errorResponse = (data as { errors: Response }).errors;
+ const errorText = await errorResponse.text().catch(() =>
errorResponse.statusText);
+ throw new Error(
+ `Failed to list properties: ${errorResponse.status}
${errorResponse.statusText} - ${errorText}`,
+ );
+ }
+
+ return (data as { properties?: ResourceMetadata[] }).properties || [];
+ } catch (error) {
+ if (error instanceof Error) {
+ throw error;
+ }
+ throw new Error(`Failed to list properties: ${String(error)}`);
+ }
+ }
+
+ /**
+ * List topN aggregations in a group.
+ */
+ async listTopN(group: string, timeoutMs: number = 30000):
Promise<ResourceMetadata[]> {
+ const url =
`${this.baseUrl}/v1/topn-agg/schema/lists/${encodeURIComponent(group)}`;
- const response = await fetch(url, {
+ try {
+ const data = (await httpFetch({
+ url,
method: 'GET',
- headers: {
- 'Content-Type': 'application/json',
- },
- signal: controller.signal,
- });
+ json: null,
+ })) as { topNAggregation?: ResourceMetadata[] } | { errors: Response };
- clearTimeout(timeoutId);
+ // Check if httpFetch returned an error
+ if (data && typeof data === 'object' && 'errors' in data) {
+ const errorResponse = (data as { errors: Response }).errors;
+ const errorText = await errorResponse.text().catch(() =>
errorResponse.statusText);
+ throw new Error(
+ `Failed to list topN aggregations: ${errorResponse.status}
${errorResponse.statusText} - ${errorText}`,
+ );
+ }
- if (!response.ok) {
- const errorText = await response.text();
- throw new Error(`Failed to list properties: ${response.status}
${response.statusText} - ${errorText}`);
+ return (data as { topNAggregation?: ResourceMetadata[]
}).topNAggregation || [];
+ } catch (error) {
+ if (error instanceof Error) {
+ if (error.name === 'AbortError' || error.message.includes('aborted')
|| error.message.includes('timeout')) {
+ throw new Error(
+ `List topN aggregations timeout after ${timeoutMs}ms. ` +
+ `BanyanDB may be slow or unresponsive. ` +
+ `Check that BanyanDB is running and accessible at
${this.baseUrl}`,
+ );
+ }
+ if (
+ error.message.includes('fetch failed') ||
+ error.message.includes('ECONNREFUSED') ||
+ error.message.includes('Failed to fetch') ||
+ error.name === 'TypeError'
+ ) {
+ throw new Error(
+ `Failed to connect to BanyanDB at ${this.baseUrl}. ` + `Please
ensure BanyanDB is running and accessible.`,
+ );
+ }
+ throw error;
}
+ throw new Error(`Failed to list topN aggregations: ${String(error)}`);
+ }
+ }
+
+ /**
+ * List index rules in a group.
+ */
+ async listIndexRule(group: string, timeoutMs: number = 30000):
Promise<ResourceMetadata[]> {
+ const url =
`${this.baseUrl}/v1/index-rule/schema/lists/${encodeURIComponent(group)}`;
- const data = (await response.json()) as {
- properties?: ResourceMetadata[];
- };
- return data.properties || [];
+ try {
+ const data = (await httpFetch({
+ url,
+ method: 'GET',
+ json: null,
+ })) as { indexRule?: ResourceMetadata[] } | { errors: Response };
+
+ if (data && typeof data === 'object' && 'errors' in data) {
+ const errorResponse = (data as { errors: Response }).errors;
+ const errorText = await errorResponse.text().catch(() =>
errorResponse.statusText);
+ throw new Error(
+ `Failed to list index rules: ${errorResponse.status}
${errorResponse.statusText} - ${errorText}`,
+ );
+ }
+
+ return (data as { indexRule?: ResourceMetadata[] }).indexRule || [];
} catch (error) {
if (error instanceof Error) {
+ if (error.name === 'AbortError' || error.message.includes('aborted')
|| error.message.includes('timeout')) {
+ throw new Error(
+ `List index rules timeout after ${timeoutMs}ms. ` +
+ `BanyanDB may be slow or unresponsive. ` +
+ `Check that BanyanDB is running and accessible at
${this.baseUrl}`,
+ );
+ }
+ if (
+ error.message.includes('fetch failed') ||
+ error.message.includes('ECONNREFUSED') ||
+ error.message.includes('Failed to fetch') ||
+ error.name === 'TypeError'
+ ) {
+ throw new Error(
+ `Failed to connect to BanyanDB at ${this.baseUrl}. ` + `Please
ensure BanyanDB is running and accessible.`,
+ );
+ }
throw error;
}
- throw new Error(`Failed to list properties: ${String(error)}`);
+ throw new Error(`Failed to list index rules: ${String(error)}`);
}
}
@@ -550,17 +500,17 @@ export class BanyanDBClient {
// Parse JSON string to object
const group = JSON.parse(groupJson);
- const response = await fetch(url, {
+ const data = (await httpFetch({
+ url,
method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({ group }),
- });
-
- if (!response.ok) {
- const errorText = await response.text();
- throw new Error(`Failed to create group: ${response.status}
${response.statusText} - ${errorText}`);
+ json: { group },
+ })) as unknown | { errors: Response };
+
+ // Check if httpFetch returned an error
+ if (data && typeof data === 'object' && 'errors' in data) {
+ const errorResponse = (data as { errors: Response }).errors;
+ const errorText = await errorResponse.text().catch(() =>
errorResponse.statusText);
+ throw new Error(`Failed to create group: ${errorResponse.status}
${errorResponse.statusText} - ${errorText}`);
}
return `Group "${group.metadata?.name || 'unknown'}" created
successfully`;
@@ -575,3 +525,6 @@ export class BanyanDBClient {
}
}
}
+
+// Re-export types for convenience
+export type { ResourceMetadata } from './types.js';
diff --git a/mcp/src/client/types.ts b/mcp/src/client/types.ts
new file mode 100644
index 00000000..d643a8c7
--- /dev/null
+++ b/mcp/src/client/types.ts
@@ -0,0 +1,110 @@
+/**
+ * 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.
+ */
+
+interface TagValue {
+ str?: { value: string };
+ int?: { value: number };
+ float?: { value: number };
+ binaryData?: unknown;
+}
+
+interface Tag {
+ key: string;
+ value: TagValue;
+}
+
+interface TagFamily {
+ tags?: Tag[];
+}
+
+interface FieldValue {
+ int?: { value: number };
+ float?: { value: number };
+ str?: { value: string };
+ binaryData?: unknown;
+}
+
+interface Field {
+ name: string;
+ value: FieldValue;
+}
+
+interface DataPoint {
+ timestamp?: string | number;
+ sid?: string;
+ version?: string | number;
+ tagFamilies?: TagFamily[];
+ fields?: Field[];
+}
+
+interface StreamResult {
+ elements?: unknown[];
+}
+
+interface MeasureResult {
+ dataPoints?: DataPoint[];
+ data_points?: DataPoint[];
+}
+
+interface TraceResult {
+ elements?: unknown[];
+}
+
+interface PropertyResult {
+ items?: unknown[];
+}
+
+interface TopNResult {
+ lists?: unknown[];
+}
+
+export interface QueryRequest {
+ query: string;
+}
+
+export interface QueryResponse {
+ // Response can be either wrapped in result or direct
+ result?: {
+ streamResult?: StreamResult;
+ measureResult?: MeasureResult;
+ traceResult?: TraceResult;
+ propertyResult?: PropertyResult;
+ topnResult?: TopNResult;
+ };
+ // Or directly at top level
+ streamResult?: StreamResult;
+ measureResult?: MeasureResult;
+ traceResult?: TraceResult;
+ propertyResult?: PropertyResult;
+ topnResult?: TopNResult;
+}
+
+export interface Group {
+ metadata?: {
+ name?: string;
+ };
+}
+
+export interface ResourceMetadata {
+ metadata?: {
+ name?: string;
+ group?: string;
+ };
+ noSort?: boolean;
+}
diff --git a/mcp/src/index.ts b/mcp/src/index.ts
index ab37bdb0..da95de40 100644
--- a/mcp/src/index.ts
+++ b/mcp/src/index.ts
@@ -22,9 +22,9 @@ import dotenv from 'dotenv';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from
'@modelcontextprotocol/sdk/server/stdio.js';
import { CallToolRequestSchema, ListToolsRequestSchema } from
'@modelcontextprotocol/sdk/types.js';
-import { BanyanDBClient, ResourceMetadata } from './banyandb-client.js';
-import { QueryGenerator, QueryGeneratorResult } from './query-generator.js';
-import { log, setupGlobalErrorHandlers } from './logger.js';
+import { BanyanDBClient, ResourceMetadata } from './client/banyandb-client.js';
+import { QueryGenerator, QueryGeneratorResult, ResourcesByGroup } from
'./query/query-generator.js';
+import { log, setupGlobalErrorHandlers } from './utils/logger.js';
// Load environment variables first
dotenv.config();
@@ -101,7 +101,7 @@ async function main() {
description: {
type: 'string',
description:
- "Natural language description of the query (e.g., 'Show me
all error logs from the last hour', 'Get CPU metrics for service webapp')",
+ "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',
@@ -216,8 +216,44 @@ async function main() {
let bydbqlQueryResult: QueryGeneratorResult;
try {
+ // Fetch groups from BanyanDB before generating query
+ let groups: string[] = [];
+ try {
+ const groupsList = await banyandbClient.listGroups();
+ groups = groupsList.map((g) => g.metadata?.name || '').filter((n) =>
n !== '');
+ } catch (error) {
+ log.warn('Failed to fetch groups, continuing without group
information:', error instanceof Error ? error.message : String(error));
+ }
+
+ // Fetch resources from all groups before generating query
+ 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((r) => r.metadata?.name || '').filter((n)
=> n !== ''),
+ measures: measures.map((r) => r.metadata?.name || '').filter((n)
=> n !== ''),
+ traces: traces.map((r) => r.metadata?.name || '').filter((n) =>
n !== ''),
+ properties: properties.map((r) => r.metadata?.name ||
'').filter((n) => n !== ''),
+ topNItems: topNItems.map((r) => r.metadata?.name ||
'').filter((n) => n !== ''),
+ indexRule: indexRule.filter((r) => !r.noSort &&
r.metadata?.name).map((r) => r.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: [] };
+ }
+ }
+
// Generate BydbQL query from natural language description
- bydbqlQueryResult = await queryGenerator.generateQuery(description,
args || {});
+ bydbqlQueryResult = await queryGenerator.generateQuery(description,
args || {}, groups, resourcesByGroup);
} catch (error) {
if (error instanceof Error && (error.message.includes('timeout') ||
error.message.includes('Timeout'))) {
return {
@@ -241,7 +277,28 @@ async function main() {
try {
// Execute query via BanyanDB client
const result = await banyandbClient.query(bydbqlQueryResult.query);
- const resultWithDebug = `=== Query Result ===\n\n${result}\n\n===
BydbQL Query ===\n${bydbqlQueryResult.query}\n\n=== Debug Information
===\nDescription: ${bydbqlQueryResult.description}\nResource Type:
${bydbqlQueryResult.resourceType}\nResource Name:
${bydbqlQueryResult.resourceName}\nGroup:
${bydbqlQueryResult.group}${bydbqlQueryResult.explanations ? `\n\n===
Explanations ===\n${bydbqlQueryResult.explanations}` : ''}\n`;
+
+ // Build debug information section with only parameters that are
present (excluding explanations)
+ const debugParts: string[] = [];
+
+ if (bydbqlQueryResult.resourceType) {
+ debugParts.push(`Resource Type: ${bydbqlQueryResult.resourceType}`);
+ }
+ if (bydbqlQueryResult.resourceName) {
+ debugParts.push(`Resource Name: ${bydbqlQueryResult.resourceName}`);
+ }
+ if (bydbqlQueryResult.group) {
+ debugParts.push(`Group: ${bydbqlQueryResult.group}`);
+ }
+
+ const debugInfo = debugParts.length > 0
+ ? `\n\n=== Debug Information ===\n${debugParts.join('\n')}\n`
+ : '';
+ const explanations = bydbqlQueryResult.explanations
+ ? `\n\n=== Explanations ===\n${bydbqlQueryResult.explanations}\n`
+ : '';
+
+ const resultWithDebug = `=== Query Result ===\n\n${result}\n\n===
BydbQL Query ===\n${bydbqlQueryResult.query}${debugInfo}${explanations}`;
return {
content: [
diff --git a/mcp/src/llm-prompt.ts b/mcp/src/llm-prompt.ts
deleted file mode 100644
index b2cb981a..00000000
--- a/mcp/src/llm-prompt.ts
+++ /dev/null
@@ -1,199 +0,0 @@
-/**
- * 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.
- */
-
-/**
- * Generate the LLM prompt for converting natural language to BydbQL queries.
- */
-export function generateQueryPrompt(description: string, args: Record<string,
unknown>): string {
- return `You are a BydbQL query generator. Convert the following natural
language description into a valid BydbQL query.
-
-BydbQL Syntax:
-- Resource types: STREAM, MEASURE, TRACE, PROPERTY
-- TIME clause examples: TIME >= '-1h', TIME > '-1d', TIME BETWEEN '-24h' AND
'-1h'
-- Use TIME > for "from last X" (e.g., "from last day" = TIME > '-1d'), TIME >=
for "since" or "in the last X"
-- For "from last day", use TIME > '-1d' (not TIME >=)
-- LIMIT clause: LIMIT N (limits the number of results returned)
-- LIMIT clause order: Must come AFTER ORDER BY clause: SELECT ... ORDER BY
field DESC LIMIT N
-
-CRITICAL Semantic Understanding - Understanding User Intent:
-- You must understand the MEANING and INTENT of the description, not just
match patterns
-- Distinguish between "last N [time units]" vs "last N [data points/items]":
- - "last 30 hours" or "last 30 days" → TIME clause (time range)
- - "last 30 zipkin_span" or "last 30 items" or "30 data points" → LIMIT
clause (number of results)
- - "list the last 30 zipkin_span" means "list 30 data points of zipkin_span",
NOT "last 30 hours"
-- When a number appears directly before or after a resource name (e.g., "last
30 zipkin_span", "30 zipkin_span", "zipkin_span 30"), it typically refers to
the NUMBER OF DATA POINTS, not a time unit
-- When a number appears with a time unit (e.g., "last 30 hours", "30 days
ago", "past 2 weeks"), it refers to a TIME RANGE
-- Examples:
- - "list the last 30 [resource_name] order by time" → SELECT * FROM TRACE
[resource_name] IN default ORDER BY timestamp_millis DESC LIMIT 30
- - "get last 10 [resource_name]" → SELECT * FROM STREAM [resource_name] IN
default ORDER BY timestamp DESC LIMIT 10
- - "show last 5 [resource_name]" → SELECT * FROM MEASURE [resource_name] IN
default ORDER BY timestamp DESC LIMIT 5
- - "list [resource_name] from last 30 hours" → SELECT * FROM STREAM
[resource_name] IN default TIME >= '-30h'
- - "get data from last 2 days" → SELECT * FROM STREAM [resource_name] IN
default TIME >= '-2d'
-
-Resource Type Detection:
-- STREAM: for logs, events, streams (keywords: log, logs, stream, streams,
event, events)
-- MEASURE: for metrics, measurements, statistics (keywords: metric, metrics,
measure, measures, stat, stats, statistics)
-- TRACE: for traces, spans, tracing data (keywords: trace, traces, span,
spans, tracing)
-- PROPERTY: for properties, metadata, configuration (keywords: property,
properties, metadata, config)
-
-
-Resource and Group Name Patterns:
-- The user description may express resource and group relationships in
different ways:
- - Standard pattern: "resource_name in group_name" (e.g., "service_cpm_minute
in metricsMinute")
- - Alternative pattern: "resource_name of group_name" (e.g.,
"service_cpm_minute of metricsMinute")
- - Possessive pattern: "group_name's resource_name" (e.g., "metricsMinute's
service_cpm_minute")
- - Group-only pattern: "the group is group_name" or "group is group_name"
(e.g., "list log data in last day, the group is recordsLog")
-- Resource names can be:
- - Simple identifiers: "log", "metric", "trace", "stream" (these are valid
resource names)
- - Underscore-separated: "service_cpm_minute", "service_instance_cpm_minute",
"log_stream"
- - CamelCase: "logStream", "metricStream"
-- Group names can be camelCase (e.g., metricsMinute, recordsLog) or
underscore-separated (e.g., sw_metric)
-- All these patterns should generate the same BydbQL format: SELECT ... FROM
RESOURCE_TYPE resource_name IN group_name ...
-
-CRITICAL Resource Name Extraction:
-- Extract resource names from the description by looking for:
- 1. Explicit resource names mentioned before "in", "of", or possessive
markers (e.g., "service_cpm_minute in metricsMinute")
- 2. Simple identifiers that match the resource type context (e.g., "log" for
STREAM, "metric" for MEASURE)
- 3. Underscore-separated identifiers (e.g., "service_cpm_minute",
"log_stream")
-- When the description mentions a resource type keyword (log, metric, trace,
etc.) without an explicit resource name:
- - Extract the keyword itself as the resource name if it appears as a
standalone word (e.g., "list log data" → resource name: "log")
- - Examples:
- - "list log data in last day, the group is recordsLog" → type: STREAM
(from "log"), name: "log", group: "recordsLog"
- - "get metrics from metricsMinute" → type: MEASURE (from "metrics"), name:
"metric" or extract explicit name if present, group: "metricsMinute"
-- Group name extraction patterns (in order of priority):
- - "the group is group_name" or "group is group_name" → extract group_name
(e.g., "the group is recordsLog" → "recordsLog")
- - "in group_name" or "group group_name" → extract group_name
- - "of group_name" → extract group_name
- - "group_name's" → extract group_name
-- If no group name is found, use "default"
-
-CRITICAL: Choose the correct query format based on the description:
-
-1. TOPN Query (ONLY use when explicitly requested):
- - Use TOPN format if the description contains ranking keywords (e.g.,
"top", "highest", "lowest", "best", "worst") followed by a NUMBER (e.g., "top
10", "top 5", "top-N", "topN", "show top 10", "highest 5", "lowest 3", "best
10")
- - The word "show" alone does NOT indicate a TOPN query - it's just a common
verb
- - Examples that indicate TOPN: "top 10", "top 5", "show top 10", "highest
5", "lowest 3", "best 10", "top-N"
- - Examples that do NOT indicate TOPN: "show", "show me", "display", "get",
"fetch"
- - If TOPN is indicated AND the resource type is MEASURE:
- - Use: SHOW TOP N FROM MEASURE measure_name IN group_name TIME
time_condition [AGGREGATE BY agg_function] [ORDER BY [value] [ASC|DESC]]
- - Example: SHOW TOP 10 FROM MEASURE cpu_usage IN default TIME > '-1h'
AGGREGATE BY SUM ORDER BY value DESC
-
-2. Common Query (DEFAULT for all other cases):
- - Use SELECT format if the description does NOT contain ranking keywords
("top", "highest", "lowest", "best", "worst") followed by a number
- - This includes descriptions with just "show", "get", "display", "fetch",
etc. without ranking keywords + number
- - Use: SELECT fields FROM RESOURCE_TYPE resource_name IN group_name [TIME
clause] [AGGREGATE BY clause] [ORDER BY clause] [LIMIT clause]
- - Example: SELECT * FROM MEASURE cpu_usage IN default TIME > '-1h'
AGGREGATE BY SUM ORDER BY value DESC LIMIT 10
- - Example with LIMIT: SELECT * FROM TRACE zipkin_span IN default ORDER BY
timestamp_millis DESC LIMIT 30
-
-AGGREGATE BY clause Detection:
-- Syntax: AGGREGATE BY SUM | MEAN | COUNT | MAX | MIN
-- Used to aggregate data points over the time range (SUM for totals, MAX for
maximum values, MIN for minimum values, MEAN/AVG for averages, COUNT for counts)
-- Extract from natural language keywords:
- - "sum", "total", "totals", "summing" → AGGREGATE BY SUM
- - "max", "maximum", "maximize", "highest value" → AGGREGATE BY MAX
- - "min", "minimum", "minimize", "lowest value" → AGGREGATE BY MIN
- - "mean", "average", "avg", "averaging" → AGGREGATE BY MEAN
- - "count", "counting", "number of" → AGGREGATE BY COUNT
-- If the description contains an explicit "AGGREGATE BY" clause, preserve it
exactly as provided
-- Examples: AGGREGATE BY SUM, AGGREGATE BY MAX, AGGREGATE BY MEAN
-
-ORDER BY clause Detection:
-- Syntax: ORDER BY field [ASC|DESC] or ORDER BY TIME [ASC|DESC] (TIME is
shorthand for timestamps)
-- Fields are flexible: You can use any field from the resource for ordering.
Common examples include: latency, start_time, timestamp, timestamp_millis,
duration, value
-- Extract from natural language:
- - "order by", "sort by" followed by field name → ORDER BY field
- - "highest", "largest", "biggest", "longest", "slowest", "top" → ORDER BY
field DESC
- - "lowest", "smallest", "shortest", "fastest", "bottom" → ORDER BY field ASC
- - If the description contains an explicit "ORDER BY" clause, preserve it
exactly as provided
-- Examples: ORDER BY latency DESC, ORDER BY start_time ASC, ORDER BY TIME DESC
-- For TOPN queries: ORDER BY DESC (for highest values) or ORDER BY ASC (for
lowest values) - field name is optional
-
-Top-N Query Syntax (for measures):
-- Top N key (the field used for ranking) is NOT REQUIRED for measures. TOP N
queries can work without specifying a key field.
-- ORDER BY clause is OPTIONAL for top N queries on measures. If not specified,
the default ordering will be used.
-- Do NOT include LIMIT clause in TOPN queries. Use SHOW TOP N syntax instead.
-
-LIMIT clause Detection:
-- Syntax: LIMIT N (where N is a positive integer)
-- Use LIMIT when the description requests a specific NUMBER OF RESULTS or DATA
POINTS:
- - "last N [resource_name]" (e.g., "last 30 zipkin_span") → LIMIT N
- - "first N [resource_name]" → LIMIT N (with ORDER BY ASC)
- - "N [resource_name]" (e.g., "30 zipkin_span") → LIMIT N
- - "show N items" or "get N records" → LIMIT N
-- LIMIT is used to restrict the number of returned results, NOT to specify a
time range
-- When LIMIT is used, typically ORDER BY should also be included to ensure
meaningful ordering:
- - "last N" usually implies ORDER BY timestamp/time DESC LIMIT N
- - "first N" usually implies ORDER BY timestamp/time ASC LIMIT N
-- LIMIT clause MUST come AFTER ORDER BY clause in the query
-- Examples:
- - "list the last 30 zipkin_span order by time" → SELECT * FROM TRACE
zipkin_span IN default ORDER BY timestamp_millis DESC LIMIT 30
- - "get first 10 logs" → SELECT * FROM STREAM log IN default ORDER BY
timestamp ASC LIMIT 10
- - "show 5 metrics" → SELECT * FROM MEASURE metric IN default LIMIT 5
-
-CRITICAL Clause Ordering Rules (applies to ALL query types):
-- Clause order MUST be: TIME (if present), then AGGREGATE BY (if present),
then ORDER BY (if present), then LIMIT (if present)
-- AGGREGATE BY must ALWAYS come BEFORE ORDER BY
-- LIMIT must ALWAYS come AFTER ORDER BY (if ORDER BY is present)
-
-User description: "${description}"${typeof args.resource_type === 'string' &&
args.resource_type ? `\n\nIMPORTANT: Resource type is explicitly provided:
${args.resource_type.toUpperCase()}. Use this value instead of extracting from
description.` : ''}${typeof args.resource_name === 'string' &&
args.resource_name ? `\n\nIMPORTANT: Resource name is explicitly provided:
${args.resource_name}. Use this value instead of extracting from description.`
: ''}${typeof args.group === 'string' && ar [...]
-
-CRITICAL Preservation Rules:
-- Use explicitly provided values (if any) with HIGHEST PRIORITY. Only extract
from description if values are NOT explicitly provided.
-- Analyze the description to extract ALL of the following (only if not
explicitly provided):
- - Resource type (STREAM, MEASURE, TRACE, or PROPERTY) - detect from keywords
like "log", "metric", "trace"
- - Resource name - follow CRITICAL Resource Name Extraction rules above
(extract explicit names or use type keywords like "log", "metric" as resource
names)
- - Group name - follow group name extraction patterns above (look for "the
group is group_name", "in group_name", etc., or use "default" if not found)
- - TIME clause - understand semantic meaning: distinguish "last N [time
units]" (TIME clause) from "last N [data points]" (LIMIT clause)
- - LIMIT clause - extract when description requests specific number of
results (e.g., "last 30 zipkin_span" means LIMIT 30, not TIME)
- - AGGREGATE BY clause (if mentioned in natural language or explicitly)
- - ORDER BY clause (if mentioned in natural language or explicitly) - when
LIMIT is present, ORDER BY is typically needed for meaningful results
-- If the user description contains a TIME clause, you MUST preserve it exactly
as provided
-- If the user description contains an AGGREGATE BY clause, you MUST preserve
it in the generated query exactly as provided
-- If the user description contains an ORDER BY clause, you MUST preserve it in
the generated query exactly as provided
-- CRITICAL FORMAT SELECTION:
- - Check if the description contains ranking keywords ("top", "highest",
"lowest", "best", "worst") followed by a NUMBER (e.g., "top 10", "top 5",
"top-N", "topN", "show top 10", "highest 5", "lowest 3", "best 10")
- - IMPORTANT: The word "show" alone does NOT indicate TOPN - only ranking
keywords + number (e.g., "top N", "highest N", "lowest N") indicate TOPN
- - Extract the resource type from the description (STREAM, MEASURE, TRACE, or
PROPERTY)
- - Extract the resource name from the description (follow CRITICAL Resource
Name Extraction rules - extract explicit names or use type keywords as resource
names)
- - Extract the group name from the description (follow group name extraction
patterns - look for "the group is group_name", "in group_name", etc., or use
"default" if not found)
- - Extract TIME clause from natural language - CRITICAL: distinguish "last N
[time units]" from "last N [data points]"
- - Extract LIMIT clause when description requests specific number of results
(e.g., "last 30 zipkin_span" → LIMIT 30, not TIME)
- - Extract AGGREGATE BY clause from natural language or explicit clause in
description
- - Extract ORDER BY clause from natural language or explicit clause in
description - when LIMIT is present, ORDER BY is typically needed
- - If YES (contains ranking keyword + number) AND resource type is MEASURE:
Use "SHOW TOP N FROM MEASURE measure_name IN group_name TIME time_condition
[AGGREGATE BY agg_function] [ORDER BY [value] [ASC|DESC]]"
- - If NO (no ranking keyword + number) OR resource type is not MEASURE: Use
"SELECT fields FROM RESOURCE_TYPE resource_name IN group_name [TIME clause]
[AGGREGATE BY clause] [ORDER BY clause] [LIMIT clause]"
-
-CRITICAL JSON Response Requirements - Use these EXACT values in your response:
-${typeof args.resource_type === 'string' && args.resource_type ? `- "type":
MUST be "${args.resource_type.toUpperCase()}" (explicitly provided)` : '-
"type": Extract from description (STREAM, MEASURE, TRACE, or PROPERTY) or
default to STREAM'}
-${typeof args.resource_name === 'string' && args.resource_name ? `- "name":
MUST be "${args.resource_name}" (explicitly provided)` : '- "name": Extract
from description using CRITICAL Resource Name Extraction rules (extract
explicit names or use type keywords like "log", "metric", "trace" as resource
names when they appear in the description)'}
-${typeof args.group === 'string' && args.group ? `- "group": MUST be
"${args.group}" (explicitly provided)` : '- "group": Extract from description
using group name extraction patterns (look for "the group is group_name", "in
group_name", etc.'}
-- "bydbql": Generate the complete BydbQL query using the type, name, and group
values specified above along with TIME, LIMIT, AGGREGATE BY, and ORDER BY
clauses extracted from description (understand semantic meaning, not just
pattern matching)
-- "explanations": Brief explanation of what the query does and how values were
determined (mention if values were explicitly provided or extracted)
-
-Return a JSON object with the following structure:
-{
- "bydbql": "the complete BydbQL query using the values specified above",
- "group": ${typeof args.group === 'string' && args.group ? `"${args.group}"`
: '"extract from description"'},
- "name": ${typeof args.resource_name === 'string' && args.resource_name ?
`"${args.resource_name}"` : '"extract from description"'},
- "type": ${typeof args.resource_type === 'string' && args.resource_type ?
`"${args.resource_type.toUpperCase()}"` : '"extract from description
(STREAM/MEASURE/TRACE/PROPERTY)"'},
- "explanations": "brief explanation"
-}
-
-IMPORTANT: If values are explicitly provided above, you MUST use those exact
values in your JSON response. Do NOT extract different values from the
description if values are explicitly provided. Return ONLY the JSON object, no
markdown formatting or additional text.`;
-}
diff --git a/mcp/src/query/llm-prompt.ts b/mcp/src/query/llm-prompt.ts
new file mode 100644
index 00000000..07f44877
--- /dev/null
+++ b/mcp/src/query/llm-prompt.ts
@@ -0,0 +1,272 @@
+/**
+ * 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 type { ResourcesByGroup } from './types.js';
+
+/**
+ * Generate the LLM prompt for converting natural language to BydbQL queries.
+ */
+export function generateQueryPrompt(
+ description: string,
+ args: Record<string, unknown>,
+ groups: string[] = [],
+ resourcesByGroup: ResourcesByGroup = {},
+): string {
+ const groupsInfo =
+ groups.length > 0
+ ? `\n\nAvailable Groups in BanyanDB:\n${groups.map((g) => `-
${g}`).join('\n')}\n\nWhen extracting group names from the description, prefer
using one of these available groups. If the description mentions a group that
doesn't exist in this list, you may still use it, but prefer matching available
groups when possible.`
+ : '';
+
+ // Build resources information - organized as resource -> group mapping for
easy lookup
+ let resourcesInfo = '';
+ const resourceToGroupMap: Record<string, { type: string; group: string }> =
{};
+ const groupsWithResources = Object.keys(resourcesByGroup).filter((group) => {
+ const resources = resourcesByGroup[group];
+ return (
+ resources.streams.length > 0 ||
+ resources.measures.length > 0 ||
+ resources.traces.length > 0 ||
+ resources.properties.length > 0 ||
+ resources.topNItems.length > 0 ||
+ resources.indexRule.length > 0
+ );
+ });
+
+ // Build resource-to-group mapping
+ for (const group of groupsWithResources) {
+ const resources = resourcesByGroup[group];
+ for (const stream of resources.streams) {
+ if (stream) resourceToGroupMap[stream] = { type: 'STREAM', group };
+ }
+ for (const measure of resources.measures) {
+ if (measure) resourceToGroupMap[measure] = { type: 'MEASURE', group };
+ }
+ for (const trace of resources.traces) {
+ if (trace) resourceToGroupMap[trace] = { type: 'TRACE', group };
+ }
+ for (const property of resources.properties) {
+ if (property) resourceToGroupMap[property] = { type: 'PROPERTY', group };
+ }
+ for (const topNItem of resources.topNItems) {
+ if (topNItem) resourceToGroupMap[topNItem] = { type: 'TOPN', group };
+ }
+ }
+
+ if (Object.keys(resourceToGroupMap).length > 0) {
+ resourcesInfo = '\n\nAvailable Resources in BanyanDB (Resource -> Group
Mapping):\n';
+ // Group by resource type for better readability
+ const resourcesByType: Record<string, Array<{ name: string; group: string
}>> = {
+ STREAM: [],
+ MEASURE: [],
+ TRACE: [],
+ PROPERTY: [],
+ TOPN: [],
+ };
+
+ for (const [resourceName, info] of Object.entries(resourceToGroupMap)) {
+ resourcesByType[info.type].push({ name: resourceName, group: info.group
});
+ }
+
+ for (const [type, resources] of Object.entries(resourcesByType)) {
+ if (resources.length > 0) {
+ resourcesInfo += `\n${type}s:\n`;
+ for (const resource of resources) {
+ resourcesInfo += ` "${resource.name}" -> Group
"${resource.group}"\n`;
+ }
+ }
+ }
+ resourcesInfo +=
+ "\nWhen extracting resource names from the description, prefer using one
of these available resources. CRITICAL: If no resource type is found in the
description, look up the resource name in this mapping to find its
corresponding resource type (STREAM, MEASURE, TRACE, PROPERTY, or TOPN). If no
group name is found in the description, look up the resource name in this
mapping to find its corresponding group. If the description mentions a resource
that doesn't exist in this list, you [...]
+ }
+
+ // Build index rules information - collect all indexed fields for ORDER BY
validation
+ let indexRulesInfo = '';
+ const allIndexedFields: string[] = [];
+ const indexedFieldsByGroup: Record<string, string[]> = {};
+ const groupsWithIndexRules = Object.keys(resourcesByGroup).filter((group) =>
{
+ const resources = resourcesByGroup[group];
+ return resources.indexRule.length > 0;
+ });
+
+ if (groupsWithIndexRules.length > 0) {
+ // Collect all indexed fields
+ for (const group of groupsWithIndexRules) {
+ const indexRules = resourcesByGroup[group].indexRule;
+ const fields: string[] = [];
+ for (const indexRule of indexRules) {
+ if (indexRule) {
+ allIndexedFields.push(indexRule);
+ fields.push(indexRule);
+ }
+ }
+ if (fields.length > 0) {
+ indexedFieldsByGroup[group] = fields;
+ }
+ }
+
+ indexRulesInfo = '\n\nAvailable Indexed Fields for ORDER BY (by Group):\n';
+ for (const group of groupsWithIndexRules) {
+ const fields = indexedFieldsByGroup[group];
+ if (fields && fields.length > 0) {
+ indexRulesInfo += `\nGroup "${group}":\n`;
+ for (const field of fields) {
+ indexRulesInfo += ` - "${field}"\n`;
+ }
+ }
+ }
+ indexRulesInfo += `\nAll Indexed Fields (across all groups):
${allIndexedFields.map((f) => `"${f}"`).join(', ')}\n`;
+ indexRulesInfo +=
+ '\nIndex Rules define which tags/fields are indexed and can be used
efficiently in ORDER BY clauses.';
+ }
+
+ return `You are a BydbQL query generator. Convert the following natural
language description into a valid BydbQL
query.${groupsInfo}${resourcesInfo}${indexRulesInfo}
+
+BydbQL Syntax:
+- Resource types: STREAM, MEASURE, TRACE, PROPERTY, TOPN
+- TIME clause: TIME >= '-1h', TIME > '-1d', TIME BETWEEN '-24h' AND '-1h'
+ - Use TIME > for "from last X" (e.g., "from last day" = TIME > '-1d')
+ - Use TIME >= for "since" or "in the last X"
+- LIMIT clause: LIMIT N (must come AFTER ORDER BY clause)
+- Clause order: TIME → AGGREGATE BY → ORDER BY → LIMIT
+
+Semantic Understanding:
+- CRITICAL: Distinguish "last N [time units]" (TIME clause) from "last N [data
points]" (LIMIT clause):
+ - "last 3 days", "last 30 hours", "last week" → TIME clause ONLY (do NOT add
ORDER BY or LIMIT)
+ - "last 30 zipkin_span", "last 10 items", "first 5 data points" → LIMIT
clause (requires ORDER BY)
+ - When a number appears directly before/after a resource name → LIMIT (data
points)
+ - When a number appears with a time unit (days, hours, minutes, etc.) → TIME
(time range) ONLY
+ - IMPORTANT: If the description only mentions a time range (e.g., "list the
last 3 days"), do NOT add ORDER BY or LIMIT clauses
+
+Resource Type Detection:
+- Detect from keywords: STREAM (log, logs, stream, event), MEASURE (metric,
measure, stat), TRACE (trace, span), PROPERTY (property, metadata, config)
+- If no keywords found, look up resource name in "Available Resources" mapping
above
+
+Resource and Group Name Extraction:
+- Resource name patterns:
+ - Explicit names before "in"/"of" (e.g., "service_cpm_minute in
metricsMinute")
+ - Type keywords as names (e.g., "list log data" → name: "log")
+ - Underscore-separated or CamelCase identifiers
+- Group name patterns (priority order):
+ - "the group is group_name" or "group is group_name"
+ - "in group_name" or "of group_name"
+ - "group_name's"
+- If no group found, look up resource name in "Available Resources" mapping
above
+
+Query Format Selection:
+
+1. TOPN Query (only for MEASURE with ranking keywords + number):
+ - Keywords: "top", "highest", "lowest", "best", "worst" followed by NUMBER
+ - Format: SHOW TOP N FROM MEASURE measure_name IN group_name TIME
time_condition [AGGREGATE BY agg_function] [ORDER BY DESC|ASC]
+ - ORDER BY: Use only "ORDER BY DESC" or "ORDER BY ASC" (no field name)
+ - Do NOT use LIMIT clause in TOPN queries
+ - Example: SHOW TOP 10 FROM MEASURE cpu_usage IN default TIME > '-1h'
AGGREGATE BY MAX ORDER BY DESC
+
+2. Common Query (default for all other cases):
+ - Format: SELECT fields FROM RESOURCE_TYPE resource_name IN group_name
[TIME clause] [AGGREGATE BY clause] [ORDER BY clause] [LIMIT clause]
+ - Example: SELECT * FROM TRACE zipkin_span IN default ORDER BY
timestamp_millis DESC LIMIT 30
+
+Clause Detection:
+
+AGGREGATE BY:
+- Keywords: "sum"/"total" → SUM, "max"/"maximum" → MAX, "min"/"minimum" → MIN,
"mean"/"average" → MEAN, "count" → COUNT
+- Preserve explicit clauses exactly as provided
+
+ORDER BY:
+- CRITICAL: Only add ORDER BY clause when explicitly requested in the
description
+- Do NOT add ORDER BY for time range queries (e.g., "list the last 3 days"
should NOT have ORDER BY)
+- Syntax: ORDER BY field [ASC|DESC] or ORDER BY TIME [ASC|DESC]
+- Keywords: "highest"/"largest" → DESC, "lowest"/"smallest" → ASC
+- For TOPN queries: Use only "ORDER BY DESC" or "ORDER BY ASC" (no field name)
+- CRITICAL: Field Validation for ORDER BY:
+ - ORDER BY can ONLY use fields that exist in the "Available Indexed Fields"
list above
+ - If no indexed fields are available, DO NOT add ORDER BY clause (the query
will fail otherwise)
+ - When extracting an ORDER BY field from the user description, you MUST
check if that field exists in the "Available Indexed Fields" list above
+ - Extract the field name (excluding ASC/DESC keywords) and compare it
against the indexed fields
+ - If the field exists in the indexed fields list → use it as-is
+ - If the field does NOT exist in the indexed fields list → find the most
similar field from the indexed fields list and use that instead
+ - Similarity matching: Look for fields that:
+ * Share common prefixes/suffixes (e.g., "timestamp" vs "timestamp_millis")
+ * Have similar names (e.g., "time" vs "timestamp", "id" vs "trace_id")
+ * Match partial words (e.g., "millis" matches "timestamp_millis")
+ - If no similar field is found in the indexed fields list → DO NOT add ORDER
BY clause
+ - Always preserve the ASC/DESC direction from the original description
+ - Example: If user says "ORDER BY time DESC" but only "timestamp_millis" is
indexed → use "ORDER BY timestamp_millis DESC"
+ - Example: If user says "ORDER BY start_time DESC" but "start_time" is not
indexed and no similar field exists → DO NOT add ORDER BY clause
+- Preserve explicit clauses exactly as provided, but validate the field name
against indexed fields
+
+LIMIT:
+- CRITICAL: Only add LIMIT clause when explicitly requesting a specific number
of data points/results
+- Do NOT add LIMIT for time range queries (e.g., "list the last 3 days" should
NOT have LIMIT)
+- Use when requesting specific number of results: "last N [resource]", "first
N [resource]", "N [resource]" (where N is a count of data points, not time
units)
+- Must come AFTER ORDER BY clause (if ORDER BY is present)
+- If LIMIT is requested, ORDER BY is typically required for meaningful results
+- Typically pair with ORDER BY: "last N items" → ORDER BY timestamp DESC LIMIT
N
+- IMPORTANT: "last 3 days" is a TIME clause, NOT a LIMIT clause. Do NOT add
LIMIT 3 for time ranges.
+
+User description: "${description}"${typeof args.resource_type === 'string' &&
args.resource_type ? `\n\nIMPORTANT: Resource type is explicitly provided:
${args.resource_type.toUpperCase()}. Use this value instead of extracting from
description.` : ''}${typeof args.resource_name === 'string' &&
args.resource_name ? `\n\nIMPORTANT: Resource name is explicitly provided:
${args.resource_name}. Use this value instead of extracting from description.`
: ''}${typeof args.group === 'string' && ar [...]
+
+Processing Rules:
+- Use explicitly provided values with HIGHEST PRIORITY
+- Extract from description only if not explicitly provided:
+ - Resource type: Detect from keywords or lookup in mapping
+ - Resource name: Extract explicit names or use type keywords
+ - Group name: Extract using patterns or lookup in mapping
+ - TIME clause: Distinguish time range from data points
+ - LIMIT clause: Extract ONLY when requesting number of data points/results
(NOT time ranges)
+ - AGGREGATE BY: Extract from keywords or preserve explicit clauses
+ - ORDER BY: Extract ONLY when explicitly requested, BUT MUST validate field
name against indexed fields list
+- CRITICAL Rules:
+ - Do NOT add ORDER BY or LIMIT unless explicitly requested in the description
+ - If description only mentions a time range (e.g., "list the last 3 days"),
use ONLY TIME clause, no ORDER BY, no LIMIT
+ - If ORDER BY is needed but no indexed fields are available, DO NOT add
ORDER BY clause
+ - If LIMIT is requested but ORDER BY field is not indexed, DO NOT add ORDER
BY (query will fail)
+- Preserve explicit TIME, AGGREGATE BY clauses exactly as provided
+- For ORDER BY: Preserve ASC/DESC direction but validate and correct field
name against indexed fields, or omit if no valid field exists
+
+JSON Response:
+- "bydbql": ALWAYS REQUIRED
+- "type": Include ONLY if description indicates debug information are needed.
${typeof args.resource_type === 'string' && args.resource_type ? `MUST be
"${args.resource_type.toUpperCase()}"` : 'Extract from description'}
+- "name": Include ONLY if description indicates debug information are needed.
${typeof args.resource_name === 'string' && args.resource_name ? `MUST be
"${args.resource_name}"` : 'Extract from description'}
+- "group": Include ONLY if description indicates debug information are needed.
${typeof args.group === 'string' && args.group ? `MUST be "${args.group}"` :
'Extract from description'}
+- "explanations": Include ONLY if description indicates explanations are
needed, if not, this parameter will not be returned.
+
+Examples:
+1. Time range query (NO ORDER BY, NO LIMIT):
+ Description: "list the last 3 days service_cpm_minute"
+ Response: {
+ "bydbql": "SELECT * FROM MEASURE service_cpm_minute IN metricsMinute TIME
> '-3d'"
+ }
+
+2. Data points query (with ORDER BY and LIMIT):
+ Description: "show the last 30 zipkin spans"
+ Response: {
+ "bydbql": "SELECT * FROM TRACE zipkin_span IN default ORDER BY
timestamp_millis DESC LIMIT 30"
+ }
+
+3. Default example:
+ Description: "show the last 30 zipkin spans order by time"
+ Response: {
+ "bydbql": "SELECT * FROM TRACE zipkin_span IN default ORDER BY
timestamp_millis DESC LIMIT 30",
+ "type": ${typeof args.resource_type === 'string' && args.resource_type ?
`"${args.resource_type.toUpperCase()}"` : '"TRACE"'},
+ "name": ${typeof args.resource_name === 'string' && args.resource_name ?
`"${args.resource_name}"` : '"zipkin_span"'},
+ "group": ${typeof args.group === 'string' && args.group ?
`"${args.group}"` : '"default"'}
+ }
+
+Return ONLY the JSON object, no markdown formatting or additional text.`;
+}
diff --git a/mcp/src/query-generator.ts b/mcp/src/query/pattern-matcher.ts
similarity index 70%
rename from mcp/src/query-generator.ts
rename to mcp/src/query/pattern-matcher.ts
index 9cef9424..bd6846dd 100644
--- a/mcp/src/query-generator.ts
+++ b/mcp/src/query/pattern-matcher.ts
@@ -17,41 +17,10 @@
* under the License.
*/
-import OpenAI from 'openai';
-import { generateQueryPrompt } from './llm-prompt.js';
-
-export type QueryGeneratorResult = {
- description: string;
- resourceType: string;
- resourceName: string;
- group: string;
- query: string;
- explanations?: string;
-};
-
/**
- * QueryGenerator converts natural language descriptions to BydbQL queries.
- * Supports both LLM-based generation (when API key is provided) and
pattern-based fallback.
+ * Pattern matcher utilities for extracting query components from natural
language descriptions.
*/
-export class QueryGenerator {
- private static readonly OPENAI_API_TIMEOUT_MS = 20000; // 20 seconds timeout
for LLM API calls
-
- private openaiClient: OpenAI | null = null;
-
- constructor(apiKey?: string, baseURL?: string) {
- // Validate API key format before creating client
- if (apiKey && apiKey.trim().length > 0) {
- const trimmedKey = apiKey.trim();
- if (trimmedKey.length < 10) {
- console.error('[QueryGenerator] Warning: API key appears to be too
short. LLM query generation may fail.');
- }
- this.openaiClient = new OpenAI({
- apiKey: trimmedKey,
- ...(baseURL && { baseURL }),
- });
- }
- }
-
+export class PatternMatcher {
private timePatterns: RegExp[] = [
/(last|past|recent)\s+(\d+)\s*(hour|hours|hr|hrs|h)/i,
/(last|past|recent)\s+(\d+)\s*(minute|minutes|min|mins|m)/i,
@@ -71,223 +40,10 @@ export class QueryGenerator {
['property', /(property|properties|metadata|config)/i],
]);
- /**
- * Generate a BydbQL query from a natural language description.
- */
- async generateQuery(description: string, args: Record<string, unknown>):
Promise<QueryGeneratorResult> {
- // Use LLM if available, otherwise fall back to pattern matching
- if (this.openaiClient) {
- try {
- return await this.generateQueryWithLLM(description, args);
- } catch (error: unknown) {
- // Check for API key authentication errors
- const errorObj = error as { status?: number; message?: string };
- if (
- errorObj?.status === 401 ||
- errorObj?.message?.includes('401') ||
- errorObj?.message?.includes('Invalid API key')
- ) {
- console.error('[QueryGenerator] API key authentication failed.
Falling back to pattern-based generation.');
- console.error('[QueryGenerator] Error details:', errorObj.message ||
String(error));
- // Disable LLM client to prevent repeated failures
- this.openaiClient = null;
- } else {
- // For other errors (timeout, network, etc.), log but don't disable
- console.error('[QueryGenerator] Error generating query with LLM:',
errorObj.message || String(error));
- }
- // Fall through to pattern-based generation
- }
- }
- return this.generateQueryWithPatterns(description, args);
- }
-
- /**
- * Generate query using LLM (OpenAI).
- */
- private async generateQueryWithLLM(
- description: string,
- args: Record<string, unknown>,
- ): Promise<QueryGeneratorResult> {
- if (!this.openaiClient) {
- throw new Error('OpenAI client not initialized');
- }
- const prompt = generateQueryPrompt(description, args);
-
- const completion = await Promise.race([
- this.openaiClient.chat.completions.create({
- model: 'gpt-4o-mini',
- messages: [
- {
- role: 'system',
- content:
- 'You are a BydbQL query generator. Return a JSON object with the
following fields: bydbql (the BydbQL query), group (group name), name (resource
name), type (resource type), and explanations (brief explanation of the
query).',
- },
- {
- role: 'user',
- content: prompt,
- },
- ],
- response_format: { type: 'json_object' },
- }),
- new Promise<never>((_, reject) =>
- setTimeout(
- () => reject(new Error(`LLM API timeout after
${QueryGenerator.OPENAI_API_TIMEOUT_MS / 1000} seconds`)),
- QueryGenerator.OPENAI_API_TIMEOUT_MS,
- ),
- ),
- ]);
-
- const responseContent = completion.choices[0]?.message?.content?.trim();
- if (!responseContent) {
- throw new Error('Empty response from LLM');
- }
-
- // Parse JSON response
- let parsedResponse: {
- bydbql?: string;
- group?: string;
- name?: string;
- type?: string;
- explanations?: string;
- };
-
- try {
- parsedResponse = JSON.parse(responseContent);
- } catch (error) {
- console.error('JSON parsing failed:', error);
- // Fallback: try to extract query from plain text if JSON parsing fails
- const cleanedQuery = responseContent
- .replace(/^```(?:bydbql|sql|json)?\n?/i, '')
- .replace(/\n?```$/i, '')
- .trim();
- return {
- description,
- resourceType: (args.resource_type as string) || 'stream',
- resourceName: (args.resource_name as string) || '',
- group: (args.group as string) || 'default',
- query: cleanedQuery,
- };
- }
-
- const query = parsedResponse.bydbql?.trim() || '';
- const group = (args.group as string) || parsedResponse.group?.trim() || '';
- const resourceName = (args.resource_name as string) ||
parsedResponse.name?.trim() || '';
- const resourceType = (args.resource_type as string) ||
parsedResponse.type?.toLowerCase().trim() || '';
- const explanations = parsedResponse.explanations?.trim() || '';
-
- // Clean up the query (remove markdown code blocks if present)
- const cleanedQuery = query
- .replace(/^```(?:bydbql|sql)?\n?/i, '')
- .replace(/\n?```$/i, '')
- .trim();
-
- // Return parameters used for query generation
- return {
- description,
- resourceType,
- resourceName,
- group,
- query: cleanedQuery,
- explanations: explanations || undefined,
- };
- }
-
- /**
- * Generate query using pattern matching (fallback method).
- */
- private generateQueryWithPatterns(description: string, args: Record<string,
unknown>): QueryGeneratorResult {
- // Determine resource type
- const resourceType = this.detectResourceType(description, args) ||
'stream';
-
- // Extract resource name if provided
- let resourceName =
- (typeof args.resource_name === 'string' ? args.resource_name : null) ||
this.detectResourceName(description);
- if (!resourceName) {
- // Use common defaults
- switch (resourceType) {
- case 'stream':
- resourceName = 'sw';
- break;
- case 'measure':
- resourceName = 'service_cpm';
- break;
- case 'trace':
- resourceName = 'sw';
- break;
- case 'property':
- resourceName = 'sw';
- break;
- }
- }
-
- // Extract group
- const group = (typeof args.group === 'string' ? args.group : null) ||
this.detectGroup(description) || 'default';
-
- // Build time clause
- const timeClause = this.buildTimeClause(description);
-
- // Build ORDER BY clause
- let orderByClause = this.buildOrderByClause(description);
-
- // Build AGGREGATE BY clause
- const aggregateByClause = this.buildAggregateByClause(description);
-
- // Build LIMIT clause (must come after ORDER BY)
- const limitClause = this.buildLimitClause(description);
-
- // If LIMIT is present but ORDER BY is not, add default ORDER BY for
meaningful results
- // "last N" typically means "most recent N", so order by time DESC
- if (limitClause && !orderByClause) {
- // Determine appropriate time field based on resource type
- let timeField = 'timestamp_millis'; // default for TRACE
- if (resourceType === 'stream') {
- timeField = 'timestamp';
- } else if (resourceType === 'measure') {
- timeField = 'timestamp';
- }
-
- // Check if description says "first N" (ascending) vs "last N"
(descending)
- const lowerDescription = description.toLowerCase();
- if (lowerDescription.includes('first')) {
- orderByClause = `ORDER BY ${timeField} ASC`;
- } else {
- // Default to DESC for "last N" patterns
- orderByClause = `ORDER BY ${timeField} DESC`;
- }
- }
-
- // Build SELECT clause
- const selectClause = this.buildSelectClause(description, resourceType);
-
- // Construct the query
- let query = `SELECT ${selectClause} FROM ${resourceType.toUpperCase()}
${resourceName} IN ${group}`;
-
- if (timeClause) {
- query += ` ${timeClause}`;
- }
- if (aggregateByClause) {
- query += ` ${aggregateByClause}`;
- }
- if (orderByClause) {
- query += ` ${orderByClause}`;
- }
- if (limitClause) {
- query += ` ${limitClause}`;
- }
-
- return {
- description,
- resourceType,
- resourceName: resourceName || '',
- group,
- query,
- };
- }
-
/**
* Detect the resource type from the description or args.
*/
- private detectResourceType(description: string, args: Record<string,
unknown>): string | null {
+ detectResourceType(description: string, args: Record<string, unknown>):
string | null {
// Check args first
if (args.resource_type && typeof args.resource_type === 'string') {
return args.resource_type.toLowerCase();
@@ -307,7 +63,7 @@ export class QueryGenerator {
/**
* Try to detect resource name from description.
*/
- private detectResourceName(description: string): string | null {
+ detectResourceName(description: string): string | null {
// Common words that shouldn't be treated as resource names
const commonWords = new Set([
'service',
@@ -398,7 +154,7 @@ export class QueryGenerator {
/**
* Try to detect group name from description.
*/
- private detectGroup(description: string): string | null {
+ detectGroup(description: string): string | null {
const patterns = [
// Pattern: "in sw_metric" or "group sw_metric" - most specific, check
first
// Match group names with underscores: sw_metric, sw_recordsLog, etc.
@@ -469,7 +225,7 @@ export class QueryGenerator {
/**
* Extract existing TIME clause from the description if present.
*/
- private extractExistingTimeClause(description: string): string | null {
+ extractExistingTimeClause(description: string): string | null {
// Pattern to match TIME clauses: TIME [operator] '[value]' or TIME
BETWEEN '[value1]' AND '[value2]'
// Match patterns like: TIME > '-24h', TIME >= '-1h', TIME BETWEEN '-24h'
AND '-1h'
const timeClausePatterns = [
@@ -497,7 +253,7 @@ export class QueryGenerator {
/**
* Build a TIME clause from the description.
*/
- private buildTimeClause(description: string): string {
+ buildTimeClause(description: string): string {
// First, check if there's already a TIME clause in the input
const existingTimeClause = this.extractExistingTimeClause(description);
if (existingTimeClause) {
@@ -619,7 +375,7 @@ export class QueryGenerator {
/**
* Build a SELECT clause from the description.
*/
- private buildSelectClause(description: string, resourceType: string): string
{
+ buildSelectClause(description: string, resourceType: string): string {
const lowerDescription = description.toLowerCase();
// Check for specific field requests
@@ -633,10 +389,48 @@ export class QueryGenerator {
return '*';
}
+ /**
+ * Extract existing ORDER BY clause from the description if present.
+ */
+ extractExistingOrderByClause(description: string): string | null {
+ // Pattern to match ORDER BY clauses: ORDER BY [field] [ASC|DESC]
+ // Match patterns like: ORDER BY value DESC, ORDER BY latency ASC, ORDER
BY DESC, etc.
+ const orderByPatterns = [
+ // ORDER BY field DESC/ASC (check this first to avoid matching field
names as direction)
+ /\bORDER\s+BY\s+(\w+)\s+(DESC|ASC|DESCENDING|ASCENDING)\b/i,
+ // ORDER BY DESC/ASC (for TOPN queries - preserve as-is without field
name)
+ /\bORDER\s+BY\s+(DESC|ASC|DESCENDING|ASCENDING)\b/i,
+ ];
+
+ for (const pattern of orderByPatterns) {
+ const match = description.match(pattern);
+ if (match) {
+ if (match[2]) {
+ // Has field name (first pattern matched)
+ const field = match[1];
+ const direction = match[2].toUpperCase().startsWith('DESC') ? 'DESC'
: 'ASC';
+ return `ORDER BY ${field} ${direction}`;
+ } else if (
+ match[1] &&
+ (match[1].toUpperCase() === 'DESC' ||
+ match[1].toUpperCase() === 'ASC' ||
+ match[1].toUpperCase() === 'DESCENDING' ||
+ match[1].toUpperCase() === 'ASCENDING')
+ ) {
+ // Only direction (for TOPN queries) - preserve as "ORDER BY DESC"
or "ORDER BY ASC"
+ const direction = match[1].toUpperCase().startsWith('DESC') ? 'DESC'
: 'ASC';
+ return `ORDER BY ${direction}`;
+ }
+ }
+ }
+
+ return null;
+ }
+
/**
* Build an ORDER BY clause from the description.
*/
- private buildOrderByClause(description: string): string {
+ buildOrderByClause(description: string): string {
// First, check if there's already an ORDER BY clause in the input
const existingOrderBy = this.extractExistingOrderByClause(description);
if (existingOrderBy) {
@@ -701,48 +495,10 @@ export class QueryGenerator {
return '';
}
- /**
- * Extract existing ORDER BY clause from the description if present.
- */
- private extractExistingOrderByClause(description: string): string | null {
- // Pattern to match ORDER BY clauses: ORDER BY [field] [ASC|DESC]
- // Match patterns like: ORDER BY value DESC, ORDER BY latency ASC, ORDER
BY DESC, etc.
- const orderByPatterns = [
- // ORDER BY field DESC/ASC (check this first to avoid matching field
names as direction)
- /\bORDER\s+BY\s+(\w+)\s+(DESC|ASC|DESCENDING|ASCENDING)\b/i,
- // ORDER BY DESC/ASC (for TOPN queries - preserve as-is without field
name)
- /\bORDER\s+BY\s+(DESC|ASC|DESCENDING|ASCENDING)\b/i,
- ];
-
- for (const pattern of orderByPatterns) {
- const match = description.match(pattern);
- if (match) {
- if (match[2]) {
- // Has field name (first pattern matched)
- const field = match[1];
- const direction = match[2].toUpperCase().startsWith('DESC') ? 'DESC'
: 'ASC';
- return `ORDER BY ${field} ${direction}`;
- } else if (
- match[1] &&
- (match[1].toUpperCase() === 'DESC' ||
- match[1].toUpperCase() === 'ASC' ||
- match[1].toUpperCase() === 'DESCENDING' ||
- match[1].toUpperCase() === 'ASCENDING')
- ) {
- // Only direction (for TOPN queries) - preserve as "ORDER BY DESC"
or "ORDER BY ASC"
- const direction = match[1].toUpperCase().startsWith('DESC') ? 'DESC'
: 'ASC';
- return `ORDER BY ${direction}`;
- }
- }
- }
-
- return null;
- }
-
/**
* Extract existing AGGREGATE BY clause from the description if present.
*/
- private extractExistingAggregateByClause(description: string): string | null
{
+ extractExistingAggregateByClause(description: string): string | null {
// Pattern to match AGGREGATE BY clauses: AGGREGATE BY [FUNCTION]
// Match patterns like: AGGREGATE BY SUM, AGGREGATE BY MAX, AGGREGATE BY
MEAN, etc.
const aggregateByPattern =
/\bAGGREGATE\s+BY\s+(SUM|MEAN|COUNT|MAX|MIN|AVG)\b/i;
@@ -761,7 +517,7 @@ export class QueryGenerator {
/**
* Build an AGGREGATE BY clause from the description.
*/
- private buildAggregateByClause(description: string): string {
+ buildAggregateByClause(description: string): string {
// First, check if there's already an AGGREGATE BY clause in the input
const existingAggregateBy =
this.extractExistingAggregateByClause(description);
if (existingAggregateBy) {
@@ -801,7 +557,7 @@ export class QueryGenerator {
* Build a LIMIT clause from the description.
* Understands semantic meaning: "last N [resource]" means LIMIT N, not TIME.
*/
- private buildLimitClause(description: string): string {
+ buildLimitClause(description: string): string {
// Pattern to match "last N [resource_name]" or "N [resource_name]" where
N is a number
// This should be interpreted as LIMIT N, not TIME
// Examples: "last 30 zipkin_span", "30 zipkin_span", "last 10 logs",
"first 5 metrics"
diff --git a/mcp/src/query/query-generator.ts b/mcp/src/query/query-generator.ts
new file mode 100644
index 00000000..86458511
--- /dev/null
+++ b/mcp/src/query/query-generator.ts
@@ -0,0 +1,299 @@
+/**
+ * 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 OpenAI from 'openai';
+import { generateQueryPrompt } from './llm-prompt.js';
+import { PatternMatcher } from './pattern-matcher.js';
+import type { QueryGeneratorResult, ResourcesByGroup } from './types.js';
+import { log } from '../utils/logger.js';
+
+/**
+ * QueryGenerator converts natural language descriptions to BydbQL queries.
+ * Supports both LLM-based generation (when API key is provided) and
pattern-based fallback.
+ */
+export class QueryGenerator {
+ private static readonly OPENAI_API_TIMEOUT_MS = 20000; // 20 seconds timeout
for LLM API calls
+
+ private openaiClient: OpenAI | null = null;
+ private patternMatcher: PatternMatcher;
+
+ constructor(apiKey?: string, baseURL?: string) {
+ // Validate API key format before creating client
+ if (apiKey && apiKey.trim().length > 0) {
+ const trimmedKey = apiKey.trim();
+ if (trimmedKey.length < 10) {
+ console.error('[QueryGenerator] Warning: API key appears to be too
short. LLM query generation may fail.');
+ }
+ this.openaiClient = new OpenAI({
+ apiKey: trimmedKey,
+ ...(baseURL && { baseURL }),
+ });
+ }
+ this.patternMatcher = new PatternMatcher();
+ }
+
+ /**
+ * Generate a BydbQL query from a natural language description.
+ */
+ async generateQuery(
+ description: string,
+ args: Record<string, unknown>,
+ groups: string[] = [],
+ resourcesByGroup: ResourcesByGroup = {},
+ ): Promise<QueryGeneratorResult> {
+ // Use LLM if available, otherwise fall back to pattern matching
+ if (this.openaiClient) {
+ try {
+ return await this.generateQueryWithLLM(description, args, groups,
resourcesByGroup);
+ } catch (error: unknown) {
+ // Check for API key authentication errors
+ const errorObj = error as { status?: number; message?: string };
+ if (
+ errorObj?.status === 401 ||
+ errorObj?.message?.includes('401') ||
+ errorObj?.message?.includes('Invalid API key')
+ ) {
+ console.error('[QueryGenerator] API key authentication failed.
Falling back to pattern-based generation.');
+ console.error('[QueryGenerator] Error details:', errorObj.message ||
String(error));
+ // Disable LLM client to prevent repeated failures
+ this.openaiClient = null;
+ } else {
+ // For other errors (timeout, network, etc.), log but don't disable
+ console.error('[QueryGenerator] Error generating query with LLM:',
errorObj.message || String(error));
+ }
+ // Fall through to pattern-based generation
+ }
+ }
+ return this.generateQueryWithPatterns(description, args);
+ }
+
+ /**
+ * Generate query using LLM (OpenAI).
+ */
+ private async generateQueryWithLLM(
+ description: string,
+ args: Record<string, unknown>,
+ groups: string[] = [],
+ resourcesByGroup: ResourcesByGroup = {},
+ ): Promise<QueryGeneratorResult> {
+ if (!this.openaiClient) {
+ throw new Error('OpenAI client not initialized');
+ }
+ const prompt = generateQueryPrompt(description, args, groups,
resourcesByGroup);
+
+ const completion = await Promise.race([
+ this.openaiClient.chat.completions.create({
+ model: 'gpt-4o-mini',
+ messages: [
+ {
+ role: 'system',
+ content:
+ 'You are a BydbQL query generator. Return a JSON object with the
following fields: bydbql (the BydbQL query), group (group name), name (resource
name), type (resource type), and explanations (brief explanation of the
query).',
+ },
+ {
+ role: 'user',
+ content: prompt,
+ },
+ ],
+ response_format: { type: 'json_object' },
+ }),
+ new Promise<never>((_, reject) =>
+ setTimeout(
+ () => reject(new Error(`LLM API timeout after
${QueryGenerator.OPENAI_API_TIMEOUT_MS / 1000} seconds`)),
+ QueryGenerator.OPENAI_API_TIMEOUT_MS,
+ ),
+ ),
+ ]);
+
+ const responseContent = completion.choices[0]?.message?.content?.trim();
+ if (!responseContent) {
+ throw new Error('Empty response from LLM');
+ }
+
+ // Parse JSON response
+ let parsedResponse: {
+ bydbql?: string;
+ group?: string;
+ name?: string;
+ type?: string;
+ explanations?: string;
+ };
+
+ try {
+ parsedResponse = JSON.parse(responseContent);
+ } catch (error) {
+ log.error('JSON parsing failed:', error);
+ // Fallback: try to extract query from plain text if JSON parsing fails
+ const cleanedQuery = responseContent
+ .replace(/^```(?:bydbql|sql|json)?\n?/i, '')
+ .replace(/\n?```$/i, '')
+ .trim();
+ const result: QueryGeneratorResult = {
+ description,
+ query: cleanedQuery,
+ };
+ const resourceType = (args.resource_type as string) || undefined;
+ const resourceName = (args.resource_name as string) || undefined;
+ const group = (args.group as string) || undefined;
+ if (resourceType) result.resourceType = resourceType;
+ if (resourceName) result.resourceName = resourceName;
+ if (group) result.group = group;
+ return result;
+ }
+
+ const query = parsedResponse.bydbql?.trim() || '';
+ const group = (args.group as string) || parsedResponse.group?.trim() ||
undefined;
+ const resourceName = (args.resource_name as string) ||
parsedResponse.name?.trim() || undefined;
+ const resourceType = (args.resource_type as string) ||
parsedResponse.type?.toLowerCase().trim() || undefined;
+ const explanations = parsedResponse.explanations?.trim() || undefined;
+
+ // Clean up the query (remove markdown code blocks if present)
+ const cleanedQuery = query
+ .replace(/^```(?:bydbql|sql)?\n?/i, '')
+ .replace(/\n?```$/i, '')
+ .trim();
+
+ // Return parameters used for query generation - only include fields that
are present
+ const result: QueryGeneratorResult = {
+ description,
+ query: cleanedQuery,
+ };
+
+ if (resourceType) {
+ result.resourceType = resourceType;
+ }
+ if (resourceName) {
+ result.resourceName = resourceName;
+ }
+ if (group) {
+ result.group = group;
+ }
+ if (explanations) {
+ result.explanations = explanations;
+ }
+
+ return result;
+ }
+
+ /**
+ * Generate query using pattern matching (fallback method).
+ */
+ private generateQueryWithPatterns(description: string, args: Record<string,
unknown>): QueryGeneratorResult {
+ // Determine resource type
+ const resourceType = this.patternMatcher.detectResourceType(description,
args) || 'stream';
+
+ // Extract resource name if provided
+ let resourceName =
+ (typeof args.resource_name === 'string' ? args.resource_name : null) ||
+ this.patternMatcher.detectResourceName(description);
+ if (!resourceName) {
+ // Use common defaults
+ switch (resourceType) {
+ case 'stream':
+ resourceName = 'sw';
+ break;
+ case 'measure':
+ resourceName = 'service_cpm';
+ break;
+ case 'trace':
+ resourceName = 'sw';
+ break;
+ case 'property':
+ resourceName = 'sw';
+ break;
+ }
+ }
+
+ // Extract group
+ const group =
+ (typeof args.group === 'string' ? args.group : null) ||
this.patternMatcher.detectGroup(description) || 'default';
+
+ // Build time clause
+ const timeClause = this.patternMatcher.buildTimeClause(description);
+
+ // Build ORDER BY clause
+ let orderByClause = this.patternMatcher.buildOrderByClause(description);
+
+ // Build AGGREGATE BY clause
+ const aggregateByClause =
this.patternMatcher.buildAggregateByClause(description);
+
+ // Build LIMIT clause (must come after ORDER BY)
+ const limitClause = this.patternMatcher.buildLimitClause(description);
+
+ // If LIMIT is present but ORDER BY is not, add default ORDER BY for
meaningful results
+ // "last N" typically means "most recent N", so order by time DESC
+ if (limitClause && !orderByClause) {
+ // Determine appropriate time field based on resource type
+ let timeField = 'timestamp_millis'; // default for TRACE
+ if (resourceType === 'stream') {
+ timeField = 'timestamp';
+ } else if (resourceType === 'measure') {
+ timeField = 'timestamp';
+ }
+
+ // Check if description says "first N" (ascending) vs "last N"
(descending)
+ const lowerDescription = description.toLowerCase();
+ if (lowerDescription.includes('first')) {
+ orderByClause = `ORDER BY ${timeField} ASC`;
+ } else {
+ // Default to DESC for "last N" patterns
+ orderByClause = `ORDER BY ${timeField} DESC`;
+ }
+ }
+
+ // Build SELECT clause
+ const selectClause = this.patternMatcher.buildSelectClause(description,
resourceType);
+
+ // Construct the query
+ let query = `SELECT ${selectClause} FROM ${resourceType.toUpperCase()}
${resourceName} IN ${group}`;
+
+ if (timeClause) {
+ query += ` ${timeClause}`;
+ }
+ if (aggregateByClause) {
+ query += ` ${aggregateByClause}`;
+ }
+ if (orderByClause) {
+ query += ` ${orderByClause}`;
+ }
+ if (limitClause) {
+ query += ` ${limitClause}`;
+ }
+
+ const result: QueryGeneratorResult = {
+ description,
+ query,
+ };
+
+ if (resourceType) {
+ result.resourceType = resourceType;
+ }
+ if (resourceName) {
+ result.resourceName = resourceName;
+ }
+ if (group) {
+ result.group = group;
+ }
+
+ return result;
+ }
+}
+
+// Re-export types for convenience
+export type { QueryGeneratorResult, ResourcesByGroup } from './types.js';
diff --git a/mcp/src/query/types.ts b/mcp/src/query/types.ts
new file mode 100644
index 00000000..13813875
--- /dev/null
+++ b/mcp/src/query/types.ts
@@ -0,0 +1,44 @@
+/**
+ * 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 type QueryGeneratorResult = {
+ description: string;
+ query: string;
+ resourceType?: string;
+ resourceName?: string;
+ group?: string;
+ explanations?: string;
+};
+
+/**
+ * Resources available in a group.
+ */
+export type GroupResources = {
+ streams: string[];
+ measures: string[];
+ traces: string[];
+ properties: string[];
+ topNItems: string[];
+ indexRule: string[];
+};
+
+/**
+ * Mapping of group names to their resources.
+ */
+export type ResourcesByGroup = Record<string, GroupResources>;
diff --git a/mcp/src/utils/http.ts b/mcp/src/utils/http.ts
new file mode 100644
index 00000000..9bcb412b
--- /dev/null
+++ b/mcp/src/utils/http.ts
@@ -0,0 +1,68 @@
+/*
+ * 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 { log } from './logger.js';
+
+const Timeout = 2 * 60 * 1000;
+
+export let globalAbortController = new AbortController();
+
+export function abortRequestsAndUpdate() {
+ globalAbortController.abort(`Request timeout ${Timeout}ms`);
+ globalAbortController = new AbortController();
+}
+
+export async function httpFetch({
+ url = '',
+ method = 'GET',
+ json,
+ headers = {},
+}: {
+ method: string;
+ json: unknown;
+ headers?: Record<string, string>;
+ url: string;
+}) {
+ const timeoutId = setTimeout(() => {
+ abortRequestsAndUpdate();
+ }, Timeout);
+
+ // Only include body and Content-Type for requests that have a body
+ const hasBody = json !== null && json !== undefined && method !== 'GET' &&
method !== 'HEAD';
+ const requestHeaders: Record<string, string> = { ...headers };
+ if (hasBody) {
+ requestHeaders['Content-Type'] = 'application/json';
+ }
+
+ const response: Response = await fetch(url, {
+ method,
+ headers: requestHeaders,
+ body: hasBody ? JSON.stringify(json) : undefined,
+ signal: globalAbortController.signal,
+ }).finally(() => {
+ clearTimeout(timeoutId);
+ });
+ if (response.ok) {
+ return response.json();
+ } else {
+ log.error('HTTP fetch failed:', response);
+ return {
+ errors: response,
+ };
+ }
+}
diff --git a/mcp/src/logger.ts b/mcp/src/utils/logger.ts
similarity index 100%
rename from mcp/src/logger.ts
rename to mcp/src/utils/logger.ts