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

Reply via email to