@pagopa/dx-mcpserver 0.0.11 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +80 -35
  2. package/dist/__tests__/__mocks__/handlers.js +48 -0
  3. package/dist/__tests__/http-endpoints.test.js +246 -0
  4. package/dist/__tests__/index.test.js +125 -0
  5. package/dist/__tests__/session.test.js +66 -0
  6. package/dist/cli.js +12 -0
  7. package/dist/config/__tests__/aws-config.test.js +15 -0
  8. package/dist/config/__tests__/constants.test.js +31 -0
  9. package/dist/config/aws.js +14 -17
  10. package/dist/config/constants.js +9 -0
  11. package/dist/config/logging.js +6 -10
  12. package/dist/config/monitoring.js +13 -5
  13. package/dist/config.js +67 -0
  14. package/dist/decorators/{promptUsageMonitoring.js → prompt-usage-monitoring.js} +8 -11
  15. package/dist/decorators/{toolUsageMonitoring.js → tool-usage-monitoring.js} +16 -16
  16. package/dist/handlers/ask.js +54 -0
  17. package/dist/handlers/search.js +60 -0
  18. package/dist/index.js +17 -59
  19. package/dist/mcp/server.js +88 -0
  20. package/dist/server.js +109 -0
  21. package/dist/services/__tests__/bedrock-retrieve-and-generate.test.js +116 -0
  22. package/dist/services/__tests__/bedrock.test.js +160 -1
  23. package/dist/services/bedrock-retrieve-and-generate.js +55 -0
  24. package/dist/services/bedrock.js +56 -0
  25. package/dist/session.js +12 -0
  26. package/dist/tools/__tests__/QueryValidation.test.js +83 -0
  27. package/dist/tools/__tests__/query-pago-pa-dx-documentation.test.js +47 -0
  28. package/dist/tools/__tests__/registry.test.js +81 -0
  29. package/dist/tools/query-pagopa-dx-documentation.js +122 -0
  30. package/dist/tools/registry.js +20 -0
  31. package/dist/types.js +1 -0
  32. package/dist/utils/__tests__/error-handling.test.js +168 -0
  33. package/dist/utils/error-handling.js +74 -0
  34. package/dist/utils/errors.js +12 -0
  35. package/dist/utils/filter-undefined.js +6 -0
  36. package/dist/utils/http.js +36 -0
  37. package/dist/utils/normalize-boolean.js +13 -0
  38. package/package.json +10 -9
  39. package/dist/auth/__tests__/githubAuth.test.js +0 -65
  40. package/dist/auth/github.js +0 -38
  41. package/dist/config/__tests__/awsConfig.test.js +0 -17
  42. package/dist/tools/QueryPagoPADXDocumentation.js +0 -35
  43. package/dist/tools/SearchGitHubCode.js +0 -84
  44. package/dist/tools/__tests__/QueryPagoPADXDocumentation.test.js +0 -22
  45. /package/dist/services/__tests__/{resolveToWebsiteUrl.test.js → resolve-to-website-url.test.js} +0 -0
package/dist/server.js ADDED
@@ -0,0 +1,109 @@
1
+ import { getLogger } from "@logtape/logtape";
2
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
3
+ import crypto from "node:crypto";
4
+ import * as http from "node:http";
5
+ import { createBedrockRuntimeClient } from "./config/aws.js";
6
+ import { handleAskEndpoint } from "./handlers/ask.js";
7
+ import { handleSearchEndpoint } from "./handlers/search.js";
8
+ import { createServer } from "./mcp/server.js";
9
+ import { sessionStorage } from "./session.js";
10
+ import { createToolDefinitions } from "./tools/registry.js";
11
+ import { parseJsonBody, sendErrorResponse, setCorsHeaders, } from "./utils/http.js";
12
+ export async function startHttpServer(config, enabledPrompts) {
13
+ const logger = getLogger(["mcpserver"]);
14
+ const awsLogger = getLogger(["mcpserver", "aws-config"]);
15
+ const kbRuntimeClient = createBedrockRuntimeClient(config.aws.region, awsLogger);
16
+ const toolDefinitions = createToolDefinitions({
17
+ aws: config.aws,
18
+ githubSearchOrg: config.github.searchOrg,
19
+ kbRuntimeClient,
20
+ });
21
+ const httpServer = http.createServer(async (req, res) => {
22
+ // Log incoming request for debugging
23
+ logger.debug("Incoming request", {
24
+ headers: req.headers,
25
+ method: req.method,
26
+ url: req.url,
27
+ });
28
+ // Configure CORS headers
29
+ setCorsHeaders(res);
30
+ // Handle OPTIONS for CORS preflight
31
+ if (req.method === "OPTIONS") {
32
+ res.writeHead(200);
33
+ res.end();
34
+ return;
35
+ }
36
+ // Handle /ask endpoint for Bedrock Knowledge Base queries
37
+ if (req.url?.includes("/ask") && req.method === "POST") {
38
+ await handleAskEndpoint(req, res, config, kbRuntimeClient);
39
+ return;
40
+ }
41
+ // Handle /search endpoint for documentation search
42
+ if (req.url?.includes("/search") && req.method === "POST") {
43
+ await handleSearchEndpoint(req, res, config, kbRuntimeClient);
44
+ return;
45
+ }
46
+ // Only allow POST and DELETE for MCP endpoints
47
+ if (req.method !== "POST" && req.method !== "DELETE") {
48
+ return sendErrorResponse(res, 405, "Method not allowed");
49
+ }
50
+ try {
51
+ // Parse request body
52
+ let jsonBody;
53
+ try {
54
+ jsonBody = await parseJsonBody(req);
55
+ }
56
+ catch {
57
+ return sendErrorResponse(res, 400, "Invalid JSON");
58
+ }
59
+ // Create session for AsyncLocalStorage context (stateless per-request)
60
+ // Extract request ID from AWS Lambda trace (undefined if not in AWS)
61
+ const traceHeader = req.headers["x-amzn-trace-id"];
62
+ const requestId = typeof traceHeader === "string"
63
+ ? traceHeader.split(";")[0].replace("Root=", "")
64
+ : undefined;
65
+ const session = {
66
+ id: crypto.randomUUID(),
67
+ requestId,
68
+ };
69
+ // Execute request in isolated session context
70
+ await sessionStorage.run(session, async () => {
71
+ // Create new server and transport for this request
72
+ // This ensures complete isolation between concurrent requests
73
+ const server = createServer({
74
+ enabledPrompts,
75
+ requestId,
76
+ toolDefinitions,
77
+ });
78
+ const transport = new StreamableHTTPServerTransport({
79
+ // This MCP server is stateless at the transport layer: each HTTP request
80
+ // runs in its own AsyncLocalStorage-backed session context via
81
+ // `sessionStorage.run(...)`. Because we do not multiplex logical sessions
82
+ // over a shared connection, transport-level session IDs are unnecessary,
83
+ // so `sessionIdGenerator` is explicitly set to `undefined`.
84
+ sessionIdGenerator: undefined,
85
+ });
86
+ await server.connect(transport);
87
+ await transport.handleRequest(req, res, jsonBody);
88
+ // Clean up after response
89
+ res.on("close", () => {
90
+ transport.close();
91
+ server.close();
92
+ });
93
+ });
94
+ }
95
+ catch (error) {
96
+ logger.error("Error handling request", { error });
97
+ if (!res.headersSent) {
98
+ sendErrorResponse(res, 500, "Internal server error");
99
+ }
100
+ }
101
+ });
102
+ await new Promise((resolve) => {
103
+ httpServer.listen(config.port, () => {
104
+ logger.info(`MCP Server started on port ${config.port}`);
105
+ resolve();
106
+ });
107
+ });
108
+ return httpServer;
109
+ }
@@ -0,0 +1,116 @@
1
+ import { getLogger } from "@logtape/logtape";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { retrieveAndGenerate } from "../bedrock-retrieve-and-generate.js";
4
+ // Helper to create mock Bedrock client
5
+ function createMockBedrockClient(sendMock) {
6
+ return {
7
+ config: {
8
+ apiVersion: "2023-11-20",
9
+ region: async () => "eu-central-1",
10
+ requestHandler: { handle: vi.fn() },
11
+ },
12
+ destroy: vi.fn(),
13
+ middlewareStack: {},
14
+ send: sendMock,
15
+ };
16
+ }
17
+ describe("retrieveAndGenerate", () => {
18
+ let loggerSpy;
19
+ beforeEach(() => {
20
+ vi.clearAllMocks();
21
+ const logger = getLogger(["mcpserver", "bedrock", "retrieve-and-generate"]);
22
+ loggerSpy = {
23
+ debug: vi.spyOn(logger, "debug"),
24
+ error: vi.spyOn(logger, "error"),
25
+ info: vi.spyOn(logger, "info"),
26
+ };
27
+ });
28
+ it("should successfully retrieve and generate with valid parameters", async () => {
29
+ const mockResponse = {
30
+ citations: [
31
+ {
32
+ retrievedReferences: [
33
+ {
34
+ content: { text: "Reference content" },
35
+ location: {
36
+ s3Location: { uri: "s3://bucket/doc.md" },
37
+ type: "S3",
38
+ },
39
+ },
40
+ ],
41
+ },
42
+ ],
43
+ output: {
44
+ text: "This is the AI-generated answer based on the knowledge base.",
45
+ },
46
+ sessionId: "test-session-123",
47
+ };
48
+ const mockClient = createMockBedrockClient(vi.fn().mockResolvedValue(mockResponse));
49
+ const result = await retrieveAndGenerate("kb-test-id", "arn:aws:bedrock:eu-central-1::foundation-model/amazon.nova-2-lite-v1:0", "How do I setup Terraform?", mockClient, 5);
50
+ expect(result).toEqual(mockResponse);
51
+ expect(result.output?.text).toBe("This is the AI-generated answer based on the knowledge base.");
52
+ expect(result.sessionId).toBe("test-session-123");
53
+ expect(loggerSpy.debug).toHaveBeenCalledWith("Calling RetrieveAndGenerate", {
54
+ knowledgeBaseId: "kb-test-id",
55
+ modelArn: "arn:aws:bedrock:eu-central-1::foundation-model/amazon.nova-2-lite-v1:0",
56
+ numberOfResults: 5,
57
+ query: "How do I setup Terraform?",
58
+ });
59
+ expect(loggerSpy.info).toHaveBeenCalledWith("RetrieveAndGenerate successful", {
60
+ sessionId: "test-session-123",
61
+ });
62
+ });
63
+ it("should use default numberOfResults when not provided", async () => {
64
+ const mockClient = createMockBedrockClient(vi.fn().mockResolvedValue({
65
+ output: { text: "Answer" },
66
+ sessionId: "session-456",
67
+ }));
68
+ await retrieveAndGenerate("kb-id", "model-arn", "test query", mockClient);
69
+ expect(loggerSpy.debug).toHaveBeenCalledWith("Calling RetrieveAndGenerate", expect.objectContaining({
70
+ numberOfResults: 5,
71
+ }));
72
+ });
73
+ it("should log error details and rethrow on failure", async () => {
74
+ const mockError = {
75
+ $metadata: {
76
+ httpStatusCode: 403,
77
+ },
78
+ __type: "AccessDeniedException",
79
+ message: "User is not authorized to perform: bedrock:RetrieveAndGenerate",
80
+ name: "AccessDeniedException",
81
+ };
82
+ const mockClient = createMockBedrockClient(vi.fn().mockRejectedValue(mockError));
83
+ await expect(retrieveAndGenerate("kb-id", "model-arn", "test query", mockClient)).rejects.toThrow();
84
+ expect(loggerSpy.error).toHaveBeenCalledWith(expect.stringContaining("RetrieveAndGenerate failed"));
85
+ expect(loggerSpy.error).toHaveBeenCalledWith(expect.stringContaining("AccessDeniedException"));
86
+ expect(loggerSpy.error).toHaveBeenCalledWith(expect.stringContaining("kb-id"));
87
+ expect(loggerSpy.error).toHaveBeenCalledWith(expect.stringContaining("model-arn"));
88
+ expect(loggerSpy.error).toHaveBeenCalledWith(expect.stringContaining("403"));
89
+ });
90
+ it("should handle errors without $metadata or __type", async () => {
91
+ const mockError = new Error("Network timeout");
92
+ const mockClient = createMockBedrockClient(vi.fn().mockRejectedValue(mockError));
93
+ await expect(retrieveAndGenerate("kb-id", "model-arn", "test query", mockClient)).rejects.toThrow("Network timeout");
94
+ expect(loggerSpy.error).toHaveBeenCalledWith(expect.stringContaining("RetrieveAndGenerate failed"));
95
+ expect(loggerSpy.error).toHaveBeenCalledWith(expect.stringContaining("Error"));
96
+ expect(loggerSpy.error).toHaveBeenCalledWith(expect.stringContaining("Network timeout"));
97
+ });
98
+ it("should send correct RetrieveAndGenerateCommand configuration", async () => {
99
+ const sendSpy = vi.fn().mockResolvedValue({
100
+ output: { text: "Test answer" },
101
+ sessionId: "test-session",
102
+ });
103
+ const mockClient = createMockBedrockClient(sendSpy);
104
+ await retrieveAndGenerate("kb-test-id", "arn:aws:bedrock:eu-central-1::foundation-model/test-model", "What is the capital of France?", mockClient, 10);
105
+ const commandArg = sendSpy.mock.calls[0][0];
106
+ expect(commandArg.input.input.text).toBe("What is the capital of France?");
107
+ expect(commandArg.input.retrieveAndGenerateConfiguration.type).toBe("KNOWLEDGE_BASE");
108
+ expect(commandArg.input.retrieveAndGenerateConfiguration
109
+ .knowledgeBaseConfiguration.knowledgeBaseId).toBe("kb-test-id");
110
+ expect(commandArg.input.retrieveAndGenerateConfiguration
111
+ .knowledgeBaseConfiguration.modelArn).toBe("arn:aws:bedrock:eu-central-1::foundation-model/test-model");
112
+ expect(commandArg.input.retrieveAndGenerateConfiguration
113
+ .knowledgeBaseConfiguration.retrievalConfiguration
114
+ .vectorSearchConfiguration.numberOfResults).toBe(10);
115
+ });
116
+ });
@@ -1,6 +1,19 @@
1
1
  import { getLogger } from "@logtape/logtape";
2
2
  import { beforeEach, describe, expect, it, vi } from "vitest";
3
- import { queryKnowledgeBase } from "../bedrock.js";
3
+ import { queryKnowledgeBase, queryKnowledgeBaseStructured, } from "../bedrock.js";
4
+ // Helper to create mock Bedrock client
5
+ function createMockBedrockClient(sendMock) {
6
+ return {
7
+ config: {
8
+ apiVersion: "2023-11-20",
9
+ region: async () => "eu-central-1",
10
+ requestHandler: { handle: vi.fn() },
11
+ },
12
+ destroy: vi.fn(),
13
+ middlewareStack: {},
14
+ send: sendMock,
15
+ };
16
+ }
4
17
  describe("queryKnowledgeBase", () => {
5
18
  let loggerSpy;
6
19
  beforeEach(() => {
@@ -47,3 +60,149 @@ describe("queryKnowledgeBase", () => {
47
60
  expect(loggerSpy.warn).toHaveBeenCalledWith("Reranking is not supported in region unsupported-region");
48
61
  });
49
62
  });
63
+ describe("queryKnowledgeBaseStructured - Basic Functionality", () => {
64
+ let loggerSpy;
65
+ beforeEach(() => {
66
+ vi.clearAllMocks();
67
+ const logger = getLogger(["mcpserver", "bedrock"]);
68
+ loggerSpy = {
69
+ warn: vi.spyOn(logger, "warn"),
70
+ };
71
+ });
72
+ it("should return structured QueryKnowledgeBasesOutput[] instead of a string", async () => {
73
+ const mockClient = createMockBedrockClient(vi.fn().mockResolvedValue({
74
+ retrievalResults: [
75
+ {
76
+ content: { text: "Documentation content", type: "TEXT" },
77
+ location: {
78
+ s3Location: { uri: "s3://bucket/docs/guide.md" },
79
+ type: "S3",
80
+ },
81
+ score: 0.95,
82
+ },
83
+ ],
84
+ }));
85
+ const result = await queryKnowledgeBaseStructured("kbId", "query", mockClient, 5, false);
86
+ expect(Array.isArray(result)).toBe(true);
87
+ expect(result.length).toBe(1);
88
+ expect(typeof result[0]).toBe("object");
89
+ });
90
+ it("should return result objects with content, location, and score properties", async () => {
91
+ const mockClient = createMockBedrockClient(vi.fn().mockResolvedValue({
92
+ retrievalResults: [
93
+ {
94
+ content: { text: "Test content", type: "TEXT" },
95
+ location: {
96
+ s3Location: { uri: "s3://bucket/path/file.md" },
97
+ type: "S3",
98
+ },
99
+ score: 0.87,
100
+ },
101
+ ],
102
+ }));
103
+ const result = await queryKnowledgeBaseStructured("kbId", "test query", mockClient);
104
+ expect(result[0]).toHaveProperty("content");
105
+ expect(result[0]).toHaveProperty("location");
106
+ expect(result[0]).toHaveProperty("score");
107
+ expect(result[0].content).toBe("Test content");
108
+ expect(result[0].score).toBe(0.87);
109
+ });
110
+ it("should resolve S3 locations to website URLs via resolveToWebsiteUrl", async () => {
111
+ const mockClient = createMockBedrockClient(vi.fn().mockResolvedValue({
112
+ retrievalResults: [
113
+ {
114
+ content: { text: "Content", type: "TEXT" },
115
+ location: {
116
+ s3Location: {
117
+ uri: "s3://pagopa-dx-documentation/docs/azure/index.md",
118
+ },
119
+ type: "S3",
120
+ },
121
+ score: 0.9,
122
+ },
123
+ ],
124
+ }));
125
+ const result = await queryKnowledgeBaseStructured("kbId", "query", mockClient);
126
+ // resolveToWebsiteUrl should transform S3 URI to website URL
127
+ expect(result[0].location).toBeDefined();
128
+ if (result[0].location?.webLocation?.url) {
129
+ expect(result[0].location.webLocation.url).toContain("https://");
130
+ // URL should have /index removed
131
+ expect(result[0].location.webLocation.url).not.toContain("/index");
132
+ }
133
+ });
134
+ it("should skip image content with warning logs", async () => {
135
+ const mockClient = createMockBedrockClient(vi.fn().mockResolvedValue({
136
+ retrievalResults: [
137
+ {
138
+ content: { text: "Valid text content", type: "TEXT" },
139
+ location: {
140
+ s3Location: { uri: "s3://bucket/file.md" },
141
+ type: "S3",
142
+ },
143
+ score: 0.9,
144
+ },
145
+ {
146
+ content: { type: "IMAGE" },
147
+ location: {
148
+ s3Location: { uri: "s3://bucket/image.png" },
149
+ type: "S3",
150
+ },
151
+ score: 0.85,
152
+ },
153
+ ],
154
+ }));
155
+ const result = await queryKnowledgeBaseStructured("kbId", "query", mockClient);
156
+ expect(result.length).toBe(1);
157
+ expect(result[0].content).toBe("Valid text content");
158
+ expect(loggerSpy.warn).toHaveBeenCalledWith("Images are not supported at this time. Skipping...");
159
+ });
160
+ });
161
+ describe("queryKnowledgeBaseStructured - Reranking", () => {
162
+ let loggerSpy;
163
+ beforeEach(() => {
164
+ vi.clearAllMocks();
165
+ const logger = getLogger(["mcpserver", "bedrock"]);
166
+ loggerSpy = {
167
+ warn: vi.spyOn(logger, "warn"),
168
+ };
169
+ });
170
+ it("should apply reranking configuration when enabled in supported region", async () => {
171
+ const sendSpy = vi.fn().mockResolvedValue({
172
+ retrievalResults: [
173
+ {
174
+ content: { text: "Reranked content", type: "TEXT" },
175
+ location: { s3Location: { uri: "s3://bucket/doc.md" }, type: "S3" },
176
+ score: 0.98,
177
+ },
178
+ ],
179
+ });
180
+ const mockClient = createMockBedrockClient(sendSpy);
181
+ // Override region for this test
182
+ mockClient.config.region = async () => "us-east-1"; // Supported region
183
+ await queryKnowledgeBaseStructured("kbId", "query", mockClient, 5, true, // Enable reranking
184
+ "AMAZON");
185
+ // Verify that send was called with reranking configuration
186
+ const commandArg = sendSpy.mock.calls[0][0];
187
+ expect(commandArg.input.retrievalConfiguration).toBeDefined();
188
+ expect(commandArg.input.retrievalConfiguration.vectorSearchConfiguration
189
+ .rerankingConfiguration).toBeDefined();
190
+ expect(commandArg.input.retrievalConfiguration.vectorSearchConfiguration
191
+ .rerankingConfiguration.bedrockRerankingConfiguration.modelConfiguration
192
+ .modelArn).toContain("amazon.rerank-v1:0");
193
+ });
194
+ it("should disable reranking in unsupported region with warning", async () => {
195
+ const sendSpy = vi.fn().mockResolvedValue({
196
+ retrievalResults: [],
197
+ });
198
+ const mockClient = createMockBedrockClient(sendSpy);
199
+ // Override region for this test
200
+ mockClient.config.region = async () => "ap-southeast-1"; // Unsupported region
201
+ await queryKnowledgeBaseStructured("kbId", "query", mockClient, 5, true);
202
+ expect(loggerSpy.warn).toHaveBeenCalledWith("Reranking is not supported in region ap-southeast-1");
203
+ // Verify that send was called WITHOUT reranking configuration
204
+ const commandArg = sendSpy.mock.calls[0][0];
205
+ expect(commandArg.input.retrievalConfiguration.vectorSearchConfiguration
206
+ .rerankingConfiguration).toBeUndefined();
207
+ });
208
+ });
@@ -0,0 +1,55 @@
1
+ import { RetrieveAndGenerateCommand, } from "@aws-sdk/client-bedrock-agent-runtime";
2
+ import { getLogger } from "@logtape/logtape";
3
+ /**
4
+ * Calls Bedrock RetrieveAndGenerate API to get an AI-generated answer
5
+ * based on documents from a knowledge base.
6
+ *
7
+ * @param knowledgeBaseId The ID of the knowledge base to query
8
+ * @param modelArn The ARN of the Bedrock model to use for generation
9
+ * @param query The user's natural language query
10
+ * @param kbAgentClient The Bedrock Agent Runtime client
11
+ * @param numberOfResults The maximum number of documents to retrieve (default: 5)
12
+ * @returns The complete RetrieveAndGenerate response including answer and citations
13
+ */
14
+ export async function retrieveAndGenerate(knowledgeBaseId, modelArn, query, kbAgentClient, numberOfResults = 5) {
15
+ const logger = getLogger(["mcpserver", "bedrock", "retrieve-and-generate"]);
16
+ try {
17
+ const command = new RetrieveAndGenerateCommand({
18
+ input: {
19
+ text: query,
20
+ },
21
+ retrieveAndGenerateConfiguration: {
22
+ knowledgeBaseConfiguration: {
23
+ knowledgeBaseId,
24
+ modelArn,
25
+ retrievalConfiguration: {
26
+ vectorSearchConfiguration: {
27
+ numberOfResults,
28
+ },
29
+ },
30
+ },
31
+ type: "KNOWLEDGE_BASE",
32
+ },
33
+ });
34
+ logger.debug("Calling RetrieveAndGenerate", {
35
+ knowledgeBaseId,
36
+ modelArn,
37
+ numberOfResults,
38
+ query,
39
+ });
40
+ const response = await kbAgentClient.send(command);
41
+ logger.info("RetrieveAndGenerate successful", {
42
+ sessionId: response.sessionId,
43
+ });
44
+ return response;
45
+ }
46
+ catch (error) {
47
+ const errorMessage = error instanceof Error ? error.message : String(error);
48
+ const errorName = error instanceof Error ? error.name : "Unknown";
49
+ const errorCode = error
50
+ .$metadata?.httpStatusCode;
51
+ const errorType = error.__type;
52
+ logger.error(`RetrieveAndGenerate failed: ${errorName} - ${errorMessage}. KB: ${knowledgeBaseId}, Model: ${modelArn}, HTTP Status: ${errorCode}, Type: ${errorType}`);
53
+ throw error;
54
+ }
55
+ }
@@ -65,6 +65,60 @@ export async function queryKnowledgeBase(knowledgeBaseId, query, kbAgentClient,
65
65
  }
66
66
  return serializeResults(documents);
67
67
  }
68
+ /**
69
+ * Returns structured results from knowledge base query without serialization.
70
+ * Same as queryKnowledgeBase but returns structured data instead of string.
71
+ */
72
+ export async function queryKnowledgeBaseStructured(knowledgeBaseId, query, kbAgentClient, numberOfResults = 5, reranking = false, rerankingModelName = "AMAZON") {
73
+ const logger = getLogger(["mcpserver", "bedrock"]);
74
+ const clientRegion = await kbAgentClient.config.region();
75
+ let rerankingEnabled = reranking;
76
+ if (reranking && !rerankingSupportedRegions.includes(clientRegion)) {
77
+ logger.warn(`Reranking is not supported in region ${clientRegion}`);
78
+ rerankingEnabled = false;
79
+ }
80
+ const retrieveRequest = {
81
+ vectorSearchConfiguration: {
82
+ numberOfResults,
83
+ },
84
+ };
85
+ if (rerankingEnabled && retrieveRequest.vectorSearchConfiguration) {
86
+ const modelNameMapping = {
87
+ AMAZON: "amazon.rerank-v1:0",
88
+ COHERE: "cohere.rerank-v3-5:0",
89
+ };
90
+ retrieveRequest.vectorSearchConfiguration.rerankingConfiguration = {
91
+ bedrockRerankingConfiguration: {
92
+ modelConfiguration: {
93
+ modelArn: `arn:aws:bedrock:${clientRegion}::foundation-model/${modelNameMapping[rerankingModelName]}`,
94
+ },
95
+ },
96
+ type: "BEDROCK_RERANKING_MODEL",
97
+ };
98
+ }
99
+ const command = new RetrieveCommand({
100
+ knowledgeBaseId,
101
+ retrievalConfiguration: retrieveRequest,
102
+ retrievalQuery: { text: query },
103
+ });
104
+ const response = await kbAgentClient.send(command);
105
+ const results = response.retrievalResults || [];
106
+ const documents = [];
107
+ for (const result of results) {
108
+ if (result.content?.type === "IMAGE") {
109
+ logger.warn("Images are not supported at this time. Skipping...");
110
+ continue;
111
+ }
112
+ else if (result.content?.text) {
113
+ documents.push({
114
+ content: result.content.text,
115
+ location: resolveToWebsiteUrl(result.location),
116
+ score: result.score || -1.0,
117
+ });
118
+ }
119
+ }
120
+ return documents;
121
+ }
68
122
  /**
69
123
  * Resolves an S3 location from a knowledge base result to a public website URL.
70
124
  * This method converts S3 URIs to publicly accessible URLs based on specific rules.
@@ -102,6 +156,8 @@ export function resolveToWebsiteUrl(location) {
102
156
  else {
103
157
  url = `https://dx.pagopa.it/docs/${key.replace(/\.md$/, "")}`;
104
158
  }
159
+ // Post-process: remove trailing /index from URLs
160
+ url = url.replace(/\/index$/, "/");
105
161
  // Return a RetrievalResultLocation object of type WEB with the computed URL
106
162
  return {
107
163
  type: "WEB",
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Session storage for stateless MCP server operations
3
+ *
4
+ * Uses AsyncLocalStorage to provide request-scoped context without global state.
5
+ * This enables the server to run in stateless environments (e.g., AWS Lambda)
6
+ * while still passing session data to tool handlers.
7
+ *
8
+ * Each HTTP request creates its own isolated session context that is automatically
9
+ * cleaned up after the request completes.
10
+ */
11
+ import { AsyncLocalStorage } from "node:async_hooks";
12
+ export const sessionStorage = new AsyncLocalStorage();
@@ -0,0 +1,83 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { QueryPagoPADXDocumentationInputSchema } from "../query-pagopa-dx-documentation.js";
3
+ // Test the Zod schema validation used in the tools
4
+ describe("Query Validation", () => {
5
+ // Use the actual schema from QueryPagoPADXDocumentationTool
6
+ const queryDocSchema = QueryPagoPADXDocumentationInputSchema;
7
+ describe("QueryPagoPADXDocumentationTool validation", () => {
8
+ it("should accept valid queries", () => {
9
+ const validQueries = [
10
+ { query: "How do I set up the project?" },
11
+ { query: "abc" }, // minimum 3 characters
12
+ { query: "What is the DX CLI?" },
13
+ { query: "Terraform Azure provider setup" },
14
+ ];
15
+ validQueries.forEach((input) => {
16
+ const result = queryDocSchema.safeParse(input);
17
+ expect(result.success).toBe(true);
18
+ });
19
+ });
20
+ it("should reject queries shorter than 3 characters", () => {
21
+ const shortQueries = [{ query: "" }, { query: "a" }, { query: "ab" }];
22
+ shortQueries.forEach((input) => {
23
+ const result = queryDocSchema.safeParse(input);
24
+ expect(result.success).toBe(false);
25
+ if (!result.success) {
26
+ expect(result.error.errors[0].message).toBe("Query must be at least 3 characters");
27
+ }
28
+ });
29
+ });
30
+ it("should reject queries exceeding 500 characters", () => {
31
+ const longQuery = { query: "a".repeat(501) };
32
+ const result = queryDocSchema.safeParse(longQuery);
33
+ expect(result.success).toBe(false);
34
+ if (!result.success) {
35
+ expect(result.error.errors[0].message).toBe("Query must not exceed 500 characters");
36
+ }
37
+ });
38
+ it("should reject missing query field", () => {
39
+ const missingQuery = {};
40
+ const result = queryDocSchema.safeParse(missingQuery);
41
+ expect(result.success).toBe(false);
42
+ });
43
+ it("should reject non-string query values", () => {
44
+ const invalidTypes = [
45
+ { query: 123 },
46
+ { query: null },
47
+ { query: undefined },
48
+ { query: [] },
49
+ { query: {} },
50
+ ];
51
+ invalidTypes.forEach((input) => {
52
+ const result = queryDocSchema.safeParse(input);
53
+ expect(result.success).toBe(false);
54
+ });
55
+ });
56
+ });
57
+ describe("Edge cases", () => {
58
+ it("should handle queries with special characters", () => {
59
+ const specialCharQueries = [
60
+ { query: "api/management@v1" },
61
+ { query: "terraform-module (azure-app-service)" },
62
+ { query: "query with 'quotes' and \"double quotes\"" },
63
+ ];
64
+ specialCharQueries.forEach((input) => {
65
+ const result = queryDocSchema.safeParse(input);
66
+ expect(result.success).toBe(true);
67
+ });
68
+ });
69
+ it("should handle very long query strings within limit", () => {
70
+ const longQuery = { query: "a".repeat(500) }; // exactly at limit
71
+ const result = queryDocSchema.safeParse(longQuery);
72
+ expect(result.success).toBe(true);
73
+ });
74
+ it("should accept whitespace-only strings if they meet min length", () => {
75
+ // Note: The Zod schema doesn't call .trim(), so whitespace-only strings will be accepted
76
+ // if they meet the minimum length requirement (3 characters for queryDoc)
77
+ const whitespaceOnly = { query: " " }; // 3 spaces = 3 characters
78
+ const result = queryDocSchema.safeParse(whitespaceOnly);
79
+ // Whitespace-only string has length >= 3, so it passes
80
+ expect(result.success).toBe(true);
81
+ });
82
+ });
83
+ });
@@ -0,0 +1,47 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ vi.mock("../../services/bedrock", () => ({
3
+ queryKnowledgeBase: vi.fn().mockResolvedValue("mocked result"),
4
+ }));
5
+ import { BedrockAgentRuntimeClient } from "@aws-sdk/client-bedrock-agent-runtime";
6
+ import { createQueryPagoPADXDocumentationTool } from "../query-pagopa-dx-documentation.js";
7
+ const QueryPagoPADXDocumentationTool = createQueryPagoPADXDocumentationTool({
8
+ kbRuntimeClient: new BedrockAgentRuntimeClient({ region: "eu-central-1" }),
9
+ knowledgeBaseId: "mockKbId",
10
+ rerankingEnabled: false,
11
+ });
12
+ describe("QueryPagoPADXDocumentationTool", () => {
13
+ it("should return results from the knowledge base", async () => {
14
+ const args = { format: "markdown", query: "test query" };
15
+ const result = await QueryPagoPADXDocumentationTool.execute(args);
16
+ expect(result).toBe("mocked result");
17
+ });
18
+ it("should use default format if not provided", async () => {
19
+ const args = { query: "test query" };
20
+ const result = await QueryPagoPADXDocumentationTool.execute(args);
21
+ expect(result).toBe("mocked result");
22
+ });
23
+ it("should reject unknown parameters due to strict schema", async () => {
24
+ const args = { query: "test query", unknownParam: "value" };
25
+ const result = await QueryPagoPADXDocumentationTool.execute(args);
26
+ expect(result).toContain("Error: Invalid input");
27
+ expect(result).toContain("Unrecognized key");
28
+ });
29
+ it("should reject queries shorter than 3 characters", async () => {
30
+ const args = { query: "ab" };
31
+ const result = await QueryPagoPADXDocumentationTool.execute(args);
32
+ expect(result).toContain("Error: Invalid input");
33
+ expect(result).toContain("Query must be at least 3 characters");
34
+ });
35
+ it("should reject empty queries", async () => {
36
+ const args = { query: "" };
37
+ const result = await QueryPagoPADXDocumentationTool.execute(args);
38
+ expect(result).toContain("Error: Invalid input");
39
+ expect(result).toContain("Query must be at least 3 characters");
40
+ });
41
+ it("should reject queries exceeding 500 characters", async () => {
42
+ const args = { query: "a".repeat(501) };
43
+ const result = await QueryPagoPADXDocumentationTool.execute(args);
44
+ expect(result).toContain("Error: Invalid input");
45
+ expect(result).toContain("Query must not exceed 500 characters");
46
+ });
47
+ });