@pagopa/dx-mcpserver 0.0.12 → 0.1.2
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.
- package/README.md +80 -35
- package/dist/__tests__/__mocks__/handlers.js +48 -0
- package/dist/__tests__/http-endpoints.test.js +246 -0
- package/dist/__tests__/index.test.js +125 -0
- package/dist/__tests__/session.test.js +66 -0
- package/dist/cli.js +12 -0
- package/dist/config/__tests__/aws-config.test.js +15 -0
- package/dist/config/__tests__/constants.test.js +31 -0
- package/dist/config/aws.js +14 -17
- package/dist/config/constants.js +9 -0
- package/dist/config/logging.js +6 -10
- package/dist/config/monitoring.js +13 -5
- package/dist/config.js +67 -0
- package/dist/decorators/{promptUsageMonitoring.js → prompt-usage-monitoring.js} +8 -11
- package/dist/decorators/{toolUsageMonitoring.js → tool-usage-monitoring.js} +16 -16
- package/dist/handlers/ask.js +54 -0
- package/dist/handlers/search.js +60 -0
- package/dist/index.js +17 -59
- package/dist/mcp/server.js +88 -0
- package/dist/server.js +109 -0
- package/dist/services/__tests__/bedrock-retrieve-and-generate.test.js +116 -0
- package/dist/services/__tests__/bedrock.test.js +160 -1
- package/dist/services/bedrock-retrieve-and-generate.js +55 -0
- package/dist/services/bedrock.js +56 -0
- package/dist/session.js +12 -0
- package/dist/tools/__tests__/QueryValidation.test.js +83 -0
- package/dist/tools/__tests__/query-pago-pa-dx-documentation.test.js +47 -0
- package/dist/tools/__tests__/registry.test.js +81 -0
- package/dist/tools/query-pagopa-dx-documentation.js +122 -0
- package/dist/tools/registry.js +20 -0
- package/dist/types.js +1 -0
- package/dist/utils/__tests__/error-handling.test.js +168 -0
- package/dist/utils/error-handling.js +74 -0
- package/dist/utils/errors.js +12 -0
- package/dist/utils/filter-undefined.js +6 -0
- package/dist/utils/http.js +36 -0
- package/dist/utils/normalize-boolean.js +13 -0
- package/package.json +7 -7
- package/dist/auth/__tests__/githubAuth.test.js +0 -65
- package/dist/auth/github.js +0 -38
- package/dist/config/__tests__/awsConfig.test.js +0 -17
- package/dist/tools/QueryPagoPADXDocumentation.js +0 -35
- package/dist/tools/SearchGitHubCode.js +0 -84
- package/dist/tools/__tests__/QueryPagoPADXDocumentation.test.js +0 -22
- /package/dist/services/__tests__/{resolveToWebsiteUrl.test.js → resolve-to-website-url.test.js} +0 -0
package/README.md
CHANGED
|
@@ -8,22 +8,88 @@ This package contains the implementation of a Model Context Protocol (MCP) serve
|
|
|
8
8
|
|
|
9
9
|
The server currently exposes the following capabilities:
|
|
10
10
|
|
|
11
|
+
### MCP Protocol
|
|
12
|
+
|
|
11
13
|
- **Tools**:
|
|
12
14
|
- `QueryPagoPADXDocumentation`: Queries Amazon Bedrock Knowledge Bases to retrieve relevant content from the [DX documentation](https://dx.pagopa.it/).
|
|
13
15
|
- `SearchGitHubCode`: Searches for code snippets in specified GitHub organization (defaults to pagopa), allowing users to find real-world examples of code usage.
|
|
14
16
|
- **Prompts**:
|
|
15
17
|
- `GenerateTerraformConfiguration`: Guides the generation of Terraform configurations following PagoPA DX best practices.
|
|
16
18
|
|
|
17
|
-
|
|
19
|
+
### REST API Endpoints
|
|
20
|
+
|
|
21
|
+
The server also exposes HTTP REST endpoints for direct documentation access:
|
|
22
|
+
|
|
23
|
+
#### POST /ask
|
|
24
|
+
|
|
25
|
+
AI-powered Q&A endpoint that generates contextual answers from the DX documentation.
|
|
26
|
+
|
|
27
|
+
**Request**:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
curl -X POST https://api.dx.pagopa.it/ask \
|
|
31
|
+
-H "Content-Type: application/json" \
|
|
32
|
+
-d '{"query": "How do I setup Terraform modules?"}'
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
**Response**:
|
|
36
|
+
|
|
37
|
+
```json
|
|
38
|
+
{
|
|
39
|
+
"answer": "To setup Terraform modules in PagoPA DX...",
|
|
40
|
+
"sources": [
|
|
41
|
+
"https://dx.pagopa.it/docs/terraform/modules/",
|
|
42
|
+
"https://dx.pagopa.it/docs/getting-started/"
|
|
43
|
+
]
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
**Features**:
|
|
48
|
+
|
|
49
|
+
- Uses Amazon Bedrock RetrieveAndGenerate for AI-generated responses
|
|
50
|
+
- Returns relevant source URLs from documentation
|
|
51
|
+
- Automatically converts internal S3 URIs to public web URLs
|
|
52
|
+
|
|
53
|
+
#### POST /search
|
|
54
|
+
|
|
55
|
+
Semantic search endpoint that retrieves relevant documentation chunks.
|
|
56
|
+
|
|
57
|
+
**Request**:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
curl -X POST https://api.dx.pagopa.it/search \
|
|
61
|
+
-H "Content-Type: application/json" \
|
|
62
|
+
-d '{
|
|
63
|
+
"query": "Azure naming conventions",
|
|
64
|
+
"number_of_results": 5
|
|
65
|
+
}'
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
**Response**:
|
|
69
|
+
|
|
70
|
+
```json
|
|
71
|
+
{
|
|
72
|
+
"query": "Azure naming conventions",
|
|
73
|
+
"results": [
|
|
74
|
+
{
|
|
75
|
+
"content": "Azure resources must follow...",
|
|
76
|
+
"score": 0.9542,
|
|
77
|
+
"source": "https://dx.pagopa.it/docs/azure/naming/"
|
|
78
|
+
}
|
|
79
|
+
]
|
|
80
|
+
}
|
|
81
|
+
```
|
|
18
82
|
|
|
19
|
-
|
|
83
|
+
**Parameters**:
|
|
20
84
|
|
|
21
|
-
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
85
|
+
- `query` (required): Natural language search query
|
|
86
|
+
- `number_of_results` (optional): Number of results to return (1-20, default: 5)
|
|
87
|
+
|
|
88
|
+
**Features**:
|
|
89
|
+
|
|
90
|
+
- Uses Amazon Bedrock Retrieve API with optional reranking
|
|
91
|
+
- Returns relevance scores for each result
|
|
92
|
+
- Configurable result count
|
|
27
93
|
|
|
28
94
|
## Usage
|
|
29
95
|
|
|
@@ -31,37 +97,21 @@ This server can be used by any MCP-compliant client.
|
|
|
31
97
|
|
|
32
98
|
### VS Code
|
|
33
99
|
|
|
34
|
-
[](vscode:mcp/install?%7B%22name%22%3A%22dx%22%2C%22type%22%3A%22http%22%2C%22url%22%3A%22https%3A%2F%2Fapi.dx.pagopa.it%2Fmcp%22%
|
|
35
|
-
|
|
36
|
-
After installing the MCP server in VS Code, you need to configure the GitHub
|
|
37
|
-
Personal Access Token (PAT) for authentication.
|
|
100
|
+
[](vscode:mcp/install?%7B%22name%22%3A%22dx%22%2C%22type%22%3A%22http%22%2C%22url%22%3A%22https%3A%2F%2Fapi.dx.pagopa.it%2Fmcp%22%7D)
|
|
38
101
|
|
|
39
|
-
|
|
102
|
+
After installing the MCP server in VS Code, update your MCP configuration file as follows:
|
|
40
103
|
|
|
41
104
|
```json
|
|
42
105
|
{
|
|
43
106
|
"servers": {
|
|
44
107
|
"dx": {
|
|
45
108
|
"url": "https://api.dx.pagopa.it/mcp",
|
|
46
|
-
"type": "http"
|
|
47
|
-
"headers": {
|
|
48
|
-
"x-gh-pat": "${input:github_mcp_pat}"
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
},
|
|
52
|
-
"inputs": [
|
|
53
|
-
{
|
|
54
|
-
"type": "promptString",
|
|
55
|
-
"id": "github_mcp_pat",
|
|
56
|
-
"description": "GitHub Personal Access Token",
|
|
57
|
-
"password": true
|
|
109
|
+
"type": "http"
|
|
58
110
|
}
|
|
59
|
-
|
|
111
|
+
}
|
|
60
112
|
}
|
|
61
113
|
```
|
|
62
114
|
|
|
63
|
-
You will be prompted to enter your GitHub PAT when you first use the server.
|
|
64
|
-
|
|
65
115
|
See [VS Code MCP docs](https://code.visualstudio.com/docs/copilot/chat/mcp-servers) for more info.
|
|
66
116
|
|
|
67
117
|
### GitHub Copilot Coding Agent
|
|
@@ -76,17 +126,12 @@ You need to configure it in the repository settings. See [GitHub Copilot MCP doc
|
|
|
76
126
|
"pagopa-dx": {
|
|
77
127
|
"url": "https://api.dx.pagopa.it/mcp",
|
|
78
128
|
"type": "http",
|
|
79
|
-
"tools": ["*"]
|
|
80
|
-
"headers": {
|
|
81
|
-
"x-gh-pat": "$COPILOT_MCP_BOT_GH_PAT"
|
|
82
|
-
}
|
|
129
|
+
"tools": ["*"]
|
|
83
130
|
}
|
|
84
131
|
}
|
|
85
132
|
}
|
|
86
133
|
```
|
|
87
134
|
|
|
88
|
-
2. **Configure Authentication**: Add any necessary tokens or secrets (e.g., `COPILOT_MCP_BOT_GH_PAT`) as secrets in the repository's Copilot configuration. This allows the coding agent to use them when querying the server.
|
|
89
|
-
|
|
90
135
|
Once configured, Copilot can autonomously invoke the MCP server's tools during task execution, using it to access documentation context and improve the quality of its code generation.
|
|
91
136
|
|
|
92
137
|
### GitHub Copilot CLI
|
|
@@ -98,7 +143,7 @@ Follow the guided wizard to start using the DX MCP server:
|
|
|
98
143
|
1. **Server Name**: `dx-docs`
|
|
99
144
|
2. **Server Type**: `2` (HTTP)
|
|
100
145
|
3. **URL**: `https://api.dx.pagopa.it/mcp`
|
|
101
|
-
4. **HTTP Headers**:
|
|
146
|
+
4. **HTTP Headers**: leave as is (no headers needed)
|
|
102
147
|
5. **Tools**: `*` (leave as is)
|
|
103
148
|
|
|
104
149
|
Use `Tab` to navigate between fields and `Ctrl+S` to save.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { vi } from "vitest";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
export const mockTool = {
|
|
4
|
+
annotations: {
|
|
5
|
+
destructiveHint: false,
|
|
6
|
+
idempotentHint: true,
|
|
7
|
+
openWorldHint: true,
|
|
8
|
+
readOnlyHint: true,
|
|
9
|
+
title: "Test Tool",
|
|
10
|
+
},
|
|
11
|
+
description: "A test tool",
|
|
12
|
+
execute: vi.fn(async (args) => {
|
|
13
|
+
const parsedResult = z.object({ input: z.string() }).safeParse(args);
|
|
14
|
+
if (!parsedResult.success) {
|
|
15
|
+
return "Error: Invalid input";
|
|
16
|
+
}
|
|
17
|
+
return `Tool executed with: ${parsedResult.data.input}`;
|
|
18
|
+
}),
|
|
19
|
+
name: "TestTool",
|
|
20
|
+
parameters: z.object({
|
|
21
|
+
input: z.string().min(1, "Input cannot be empty"),
|
|
22
|
+
}),
|
|
23
|
+
};
|
|
24
|
+
export const mockCatalogEntry = {
|
|
25
|
+
category: "test",
|
|
26
|
+
enabled: true,
|
|
27
|
+
id: "test-prompt",
|
|
28
|
+
metadata: {
|
|
29
|
+
description: "A test prompt for unit testing",
|
|
30
|
+
title: "Test Prompt",
|
|
31
|
+
},
|
|
32
|
+
prompt: {
|
|
33
|
+
arguments: [
|
|
34
|
+
{ description: "First argument", name: "arg1", required: true },
|
|
35
|
+
{ description: "Second argument", name: "arg2", required: false },
|
|
36
|
+
],
|
|
37
|
+
description: "A test prompt",
|
|
38
|
+
load: async (args) => `Prompt loaded with args: ${JSON.stringify(args)}`,
|
|
39
|
+
name: "TestPrompt",
|
|
40
|
+
},
|
|
41
|
+
tags: ["test"],
|
|
42
|
+
};
|
|
43
|
+
export const mockPromptEntry = {
|
|
44
|
+
catalogEntry: mockCatalogEntry,
|
|
45
|
+
prompt: {
|
|
46
|
+
load: vi.fn(async (args) => `Prompt loaded with args: ${JSON.stringify(args)}`),
|
|
47
|
+
},
|
|
48
|
+
};
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { loadConfig } from "../config.js";
|
|
3
|
+
// Mock the prompts package to avoid Vite resolution issues in CI
|
|
4
|
+
vi.mock("@pagopa/dx-mcpprompts", () => ({
|
|
5
|
+
getEnabledPrompts: vi.fn().mockResolvedValue([]),
|
|
6
|
+
}));
|
|
7
|
+
// Helper to create mock Bedrock responses
|
|
8
|
+
function createMockBedrockResponse(command) {
|
|
9
|
+
if (command.constructor.name === "RetrieveAndGenerateCommand") {
|
|
10
|
+
return Promise.resolve({
|
|
11
|
+
citations: [
|
|
12
|
+
{
|
|
13
|
+
retrievedReferences: [
|
|
14
|
+
{
|
|
15
|
+
content: { text: "Reference content", type: "TEXT" },
|
|
16
|
+
location: {
|
|
17
|
+
s3Location: {
|
|
18
|
+
uri: "s3://bucket/docs/terraform/setup.md",
|
|
19
|
+
},
|
|
20
|
+
type: "S3",
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
],
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
output: { text: "This is how you setup Terraform." },
|
|
27
|
+
sessionId: "test-session",
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
else if (command.constructor.name === "RetrieveCommand") {
|
|
31
|
+
return Promise.resolve({
|
|
32
|
+
retrievalResults: [
|
|
33
|
+
{
|
|
34
|
+
content: { text: "Azure naming conventions guide", type: "TEXT" },
|
|
35
|
+
location: {
|
|
36
|
+
s3Location: { uri: "s3://bucket/docs/azure/naming.md" },
|
|
37
|
+
type: "S3",
|
|
38
|
+
},
|
|
39
|
+
score: 0.95,
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
return Promise.resolve({});
|
|
45
|
+
}
|
|
46
|
+
// Mock AWS SDK to avoid external dependencies
|
|
47
|
+
vi.mock("@aws-sdk/client-bedrock-agent-runtime", () => ({
|
|
48
|
+
BedrockAgentRuntimeClient: vi.fn().mockImplementation(() => ({
|
|
49
|
+
config: {
|
|
50
|
+
apiVersion: "2023-11-20",
|
|
51
|
+
region: async () => "eu-central-1",
|
|
52
|
+
requestHandler: { handle: vi.fn() },
|
|
53
|
+
},
|
|
54
|
+
destroy: vi.fn(),
|
|
55
|
+
middlewareStack: {},
|
|
56
|
+
send: vi.fn(),
|
|
57
|
+
})),
|
|
58
|
+
RetrieveAndGenerateCommand: vi.fn(),
|
|
59
|
+
RetrieveCommand: vi.fn(),
|
|
60
|
+
}));
|
|
61
|
+
vi.mock("../config/aws.js", () => ({
|
|
62
|
+
createBedrockRuntimeClient: vi.fn(() => ({
|
|
63
|
+
config: {
|
|
64
|
+
apiVersion: "2023-11-20",
|
|
65
|
+
region: async () => "eu-central-1",
|
|
66
|
+
requestHandler: { handle: vi.fn() },
|
|
67
|
+
},
|
|
68
|
+
destroy: vi.fn(),
|
|
69
|
+
middlewareStack: {},
|
|
70
|
+
send: vi.fn().mockImplementation(createMockBedrockResponse),
|
|
71
|
+
})),
|
|
72
|
+
rerankingSupportedRegions: ["us-east-1", "eu-central-1"],
|
|
73
|
+
}));
|
|
74
|
+
// Shared server instance
|
|
75
|
+
let server;
|
|
76
|
+
let baseUrl;
|
|
77
|
+
async function closeTestServer() {
|
|
78
|
+
return new Promise((resolve) => {
|
|
79
|
+
server.close(() => resolve());
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
async function setupTestServer() {
|
|
83
|
+
const testEnv = {
|
|
84
|
+
AWS_BEDROCK_KNOWLEDGE_BASE_ID: "test-kb-id",
|
|
85
|
+
AWS_BEDROCK_MODEL_ARN: "arn:aws:bedrock:test",
|
|
86
|
+
AWS_REGION: "eu-central-1",
|
|
87
|
+
LOG_LEVEL: "error",
|
|
88
|
+
NODE_ENV: "test",
|
|
89
|
+
};
|
|
90
|
+
const config = loadConfig(testEnv);
|
|
91
|
+
const { startHttpServer } = await import("../index.js");
|
|
92
|
+
// Use empty prompts array since we mocked the module
|
|
93
|
+
server = await startHttpServer(config, []);
|
|
94
|
+
const address = server.address();
|
|
95
|
+
const port = typeof address === "object" ? address?.port : 8080;
|
|
96
|
+
baseUrl = `http://localhost:${port}`;
|
|
97
|
+
}
|
|
98
|
+
describe("HTTP Endpoints Integration Tests", () => {
|
|
99
|
+
beforeAll(setupTestServer);
|
|
100
|
+
afterAll(closeTestServer);
|
|
101
|
+
describe("POST /ask", () => {
|
|
102
|
+
it("should return 200 with answer and sources for valid request", async () => {
|
|
103
|
+
const response = await fetch(`${baseUrl}/ask`, {
|
|
104
|
+
body: JSON.stringify({ query: "How do I setup Terraform?" }),
|
|
105
|
+
headers: { "Content-Type": "application/json" },
|
|
106
|
+
method: "POST",
|
|
107
|
+
});
|
|
108
|
+
expect(response.status).toBe(200);
|
|
109
|
+
expect(response.headers.get("content-type")).toContain("application/json");
|
|
110
|
+
const data = (await response.json());
|
|
111
|
+
expect(data).toHaveProperty("answer");
|
|
112
|
+
expect(data).toHaveProperty("sources");
|
|
113
|
+
expect(typeof data.answer).toBe("string");
|
|
114
|
+
expect(Array.isArray(data.sources)).toBe(true);
|
|
115
|
+
});
|
|
116
|
+
it("should return 400 for missing query field", async () => {
|
|
117
|
+
const response = await fetch(`${baseUrl}/ask`, {
|
|
118
|
+
body: JSON.stringify({}),
|
|
119
|
+
headers: { "Content-Type": "application/json" },
|
|
120
|
+
method: "POST",
|
|
121
|
+
});
|
|
122
|
+
expect(response.status).toBe(400);
|
|
123
|
+
const data = (await response.json());
|
|
124
|
+
expect(data.error).toBe("Missing required field: query");
|
|
125
|
+
});
|
|
126
|
+
it("should return 400 for empty query string", async () => {
|
|
127
|
+
const response = await fetch(`${baseUrl}/ask`, {
|
|
128
|
+
body: JSON.stringify({ query: " " }),
|
|
129
|
+
headers: { "Content-Type": "application/json" },
|
|
130
|
+
method: "POST",
|
|
131
|
+
});
|
|
132
|
+
expect(response.status).toBe(400);
|
|
133
|
+
const data = (await response.json());
|
|
134
|
+
expect(data.error).toBe("Missing required field: query");
|
|
135
|
+
});
|
|
136
|
+
it("should return 400 for invalid JSON", async () => {
|
|
137
|
+
const response = await fetch(`${baseUrl}/ask`, {
|
|
138
|
+
body: "not valid json{",
|
|
139
|
+
headers: { "Content-Type": "application/json" },
|
|
140
|
+
method: "POST",
|
|
141
|
+
});
|
|
142
|
+
expect(response.status).toBe(400);
|
|
143
|
+
const data = (await response.json());
|
|
144
|
+
expect(data.error).toBe("Invalid JSON in request body");
|
|
145
|
+
});
|
|
146
|
+
it("should return 400 for non-string query", async () => {
|
|
147
|
+
const response = await fetch(`${baseUrl}/ask`, {
|
|
148
|
+
body: JSON.stringify({ query: 123 }),
|
|
149
|
+
headers: { "Content-Type": "application/json" },
|
|
150
|
+
method: "POST",
|
|
151
|
+
});
|
|
152
|
+
expect(response.status).toBe(400);
|
|
153
|
+
const data = (await response.json());
|
|
154
|
+
expect(data.error).toBe("Missing required field: query");
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
describe("POST /search", () => {
|
|
158
|
+
it("should return 200 with results for valid request", async () => {
|
|
159
|
+
const response = await fetch(`${baseUrl}/search`, {
|
|
160
|
+
body: JSON.stringify({ query: "Azure naming conventions" }),
|
|
161
|
+
headers: { "Content-Type": "application/json" },
|
|
162
|
+
method: "POST",
|
|
163
|
+
});
|
|
164
|
+
expect(response.status).toBe(200);
|
|
165
|
+
expect(response.headers.get("content-type")).toContain("application/json");
|
|
166
|
+
const data = (await response.json());
|
|
167
|
+
expect(data).toHaveProperty("query");
|
|
168
|
+
expect(data).toHaveProperty("results");
|
|
169
|
+
expect(Array.isArray(data.results)).toBe(true);
|
|
170
|
+
if (data.results.length > 0) {
|
|
171
|
+
const result = data.results[0];
|
|
172
|
+
expect(result).toHaveProperty("content");
|
|
173
|
+
expect(result).toHaveProperty("score");
|
|
174
|
+
expect(typeof result.score).toBe("number");
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
it("should use default number_of_results when not provided", async () => {
|
|
178
|
+
const response = await fetch(`${baseUrl}/search`, {
|
|
179
|
+
body: JSON.stringify({ query: "test query" }),
|
|
180
|
+
headers: { "Content-Type": "application/json" },
|
|
181
|
+
method: "POST",
|
|
182
|
+
});
|
|
183
|
+
expect(response.status).toBe(200);
|
|
184
|
+
const data = (await response.json());
|
|
185
|
+
expect(data).toHaveProperty("results");
|
|
186
|
+
});
|
|
187
|
+
it("should accept custom number_of_results", async () => {
|
|
188
|
+
const response = await fetch(`${baseUrl}/search`, {
|
|
189
|
+
body: JSON.stringify({ number_of_results: 10, query: "test query" }),
|
|
190
|
+
headers: { "Content-Type": "application/json" },
|
|
191
|
+
method: "POST",
|
|
192
|
+
});
|
|
193
|
+
expect(response.status).toBe(200);
|
|
194
|
+
});
|
|
195
|
+
it("should return 400 for missing query field", async () => {
|
|
196
|
+
const response = await fetch(`${baseUrl}/search`, {
|
|
197
|
+
body: JSON.stringify({}),
|
|
198
|
+
headers: { "Content-Type": "application/json" },
|
|
199
|
+
method: "POST",
|
|
200
|
+
});
|
|
201
|
+
expect(response.status).toBe(400);
|
|
202
|
+
const data = (await response.json());
|
|
203
|
+
expect(data.error).toBe("Missing required field: query");
|
|
204
|
+
});
|
|
205
|
+
it("should return 400 for empty query string", async () => {
|
|
206
|
+
const response = await fetch(`${baseUrl}/search`, {
|
|
207
|
+
body: JSON.stringify({ query: "" }),
|
|
208
|
+
headers: { "Content-Type": "application/json" },
|
|
209
|
+
method: "POST",
|
|
210
|
+
});
|
|
211
|
+
expect(response.status).toBe(400);
|
|
212
|
+
const data = (await response.json());
|
|
213
|
+
expect(data.error).toBe("Missing required field: query");
|
|
214
|
+
});
|
|
215
|
+
it("should return 400 for invalid JSON", async () => {
|
|
216
|
+
const response = await fetch(`${baseUrl}/search`, {
|
|
217
|
+
body: "{invalid json",
|
|
218
|
+
headers: { "Content-Type": "application/json" },
|
|
219
|
+
method: "POST",
|
|
220
|
+
});
|
|
221
|
+
expect(response.status).toBe(400);
|
|
222
|
+
const data = (await response.json());
|
|
223
|
+
expect(data.error).toBe("Invalid JSON in request body");
|
|
224
|
+
});
|
|
225
|
+
it("should return 400 for number_of_results out of range (too low)", async () => {
|
|
226
|
+
const response = await fetch(`${baseUrl}/search`, {
|
|
227
|
+
body: JSON.stringify({ number_of_results: 0, query: "test" }),
|
|
228
|
+
headers: { "Content-Type": "application/json" },
|
|
229
|
+
method: "POST",
|
|
230
|
+
});
|
|
231
|
+
expect(response.status).toBe(400);
|
|
232
|
+
const data = (await response.json());
|
|
233
|
+
expect(data.error).toBe("number_of_results must be between 1 and 20");
|
|
234
|
+
});
|
|
235
|
+
it("should return 400 for number_of_results out of range (too high)", async () => {
|
|
236
|
+
const response = await fetch(`${baseUrl}/search`, {
|
|
237
|
+
body: JSON.stringify({ number_of_results: 21, query: "test" }),
|
|
238
|
+
headers: { "Content-Type": "application/json" },
|
|
239
|
+
method: "POST",
|
|
240
|
+
});
|
|
241
|
+
expect(response.status).toBe(400);
|
|
242
|
+
const data = (await response.json());
|
|
243
|
+
expect(data.error).toBe("number_of_results must be between 1 and 20");
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { mockCatalogEntry, mockPromptEntry, mockTool, } from "./__mocks__/handlers.js";
|
|
3
|
+
describe("MCP Server Handlers", () => {
|
|
4
|
+
describe("Tool Handler Validation", () => {
|
|
5
|
+
it("should validate tool arguments against the Zod schema", async () => {
|
|
6
|
+
// Valid arguments should pass
|
|
7
|
+
const validArgs = { input: "test-value" };
|
|
8
|
+
const validationResult = mockTool.parameters.safeParse(validArgs);
|
|
9
|
+
expect(validationResult.success).toBe(true);
|
|
10
|
+
// Invalid arguments should fail
|
|
11
|
+
const invalidArgs = { input: "" };
|
|
12
|
+
const invalidationResult = mockTool.parameters.safeParse(invalidArgs);
|
|
13
|
+
expect(invalidationResult.success).toBe(false);
|
|
14
|
+
if (!invalidationResult.success) {
|
|
15
|
+
expect(invalidationResult.error.issues[0].message).toContain("cannot be empty");
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
it("should handle tool execution errors gracefully", async () => {
|
|
19
|
+
const errorTool = {
|
|
20
|
+
...mockTool,
|
|
21
|
+
execute: vi.fn(async () => {
|
|
22
|
+
throw new Error("Tool execution failed");
|
|
23
|
+
}),
|
|
24
|
+
};
|
|
25
|
+
await expect(errorTool.execute({ input: "test" }, undefined)).rejects.toThrow("Tool execution failed");
|
|
26
|
+
});
|
|
27
|
+
it("should pass context with session data to tool execute", async () => {
|
|
28
|
+
const context = { session: { id: "session-123" } };
|
|
29
|
+
await mockTool.execute({ input: "test" }, context);
|
|
30
|
+
expect(mockTool.execute).toHaveBeenCalledWith({ input: "test" }, context);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
describe("Prompt Handler Validation", () => {
|
|
34
|
+
it("should validate required prompt arguments", () => {
|
|
35
|
+
const missingRequiredArg = { arg2: "value2" };
|
|
36
|
+
const hasAllRequired = mockCatalogEntry.prompt.arguments
|
|
37
|
+
.filter((arg) => arg.required)
|
|
38
|
+
.every((arg) => arg.name in missingRequiredArg);
|
|
39
|
+
expect(hasAllRequired).toBe(false);
|
|
40
|
+
const withAllRequired = { arg1: "value1", arg2: "value2" };
|
|
41
|
+
const hasAllRequired2 = mockCatalogEntry.prompt.arguments
|
|
42
|
+
.filter((arg) => arg.required)
|
|
43
|
+
.every((arg) => arg.name in withAllRequired);
|
|
44
|
+
expect(hasAllRequired2).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
it("should handle missing required prompt arguments", () => {
|
|
47
|
+
const requiredArgs = mockCatalogEntry.prompt.arguments
|
|
48
|
+
.filter((arg) => arg.required)
|
|
49
|
+
.map((arg) => arg.name);
|
|
50
|
+
const providedArgs = { arg2: "value" };
|
|
51
|
+
const missingArgs = requiredArgs.filter((arg) => !(arg in providedArgs));
|
|
52
|
+
expect(missingArgs).toEqual(["arg1"]);
|
|
53
|
+
});
|
|
54
|
+
it("should handle optional prompt arguments", async () => {
|
|
55
|
+
const argsWithoutOptional = { arg1: "required-value" };
|
|
56
|
+
await mockPromptEntry.prompt.load(argsWithoutOptional);
|
|
57
|
+
expect(mockPromptEntry.prompt.load).toHaveBeenCalledWith(argsWithoutOptional);
|
|
58
|
+
const argsWithOptional = { arg1: "required-value", arg2: "optional" };
|
|
59
|
+
await mockPromptEntry.prompt.load(argsWithOptional);
|
|
60
|
+
expect(mockPromptEntry.prompt.load).toHaveBeenCalledWith(argsWithOptional);
|
|
61
|
+
});
|
|
62
|
+
it("should handle prompt loading errors gracefully", async () => {
|
|
63
|
+
const errorPrompt = {
|
|
64
|
+
...mockPromptEntry,
|
|
65
|
+
prompt: {
|
|
66
|
+
load: vi.fn(async () => {
|
|
67
|
+
throw new Error("Prompt loading failed");
|
|
68
|
+
}),
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
await expect(errorPrompt.prompt.load({ arg1: "test" })).rejects.toThrow("Prompt loading failed");
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
describe("Request Handler Integration", () => {
|
|
75
|
+
it("should handle valid tool requests", async () => {
|
|
76
|
+
const args = { input: "test-input" };
|
|
77
|
+
const result = await mockTool.execute(args, undefined);
|
|
78
|
+
expect(result).toContain("Tool executed with: test-input");
|
|
79
|
+
expect(mockTool.execute).toHaveBeenCalledWith(args, undefined);
|
|
80
|
+
});
|
|
81
|
+
it("should reject requests with invalid tool names", () => {
|
|
82
|
+
const toolRegistry = new Map();
|
|
83
|
+
toolRegistry.set("TestTool", mockTool);
|
|
84
|
+
const toolExists = toolRegistry.has("NonExistentTool");
|
|
85
|
+
expect(toolExists).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
it("should handle tool execution with proper error context", async () => {
|
|
88
|
+
const toolWithErrorContext = {
|
|
89
|
+
...mockTool,
|
|
90
|
+
execute: vi.fn(async (args, context) => {
|
|
91
|
+
if (!context?.session) {
|
|
92
|
+
throw new Error("Session context required");
|
|
93
|
+
}
|
|
94
|
+
return "Success with context";
|
|
95
|
+
}),
|
|
96
|
+
};
|
|
97
|
+
// Should fail without context
|
|
98
|
+
await expect(toolWithErrorContext.execute({ input: "test" }, undefined)).rejects.toThrow("Session context required");
|
|
99
|
+
// Should succeed with context
|
|
100
|
+
const result = await toolWithErrorContext.execute({ input: "test" }, { session: { id: "session-1" } });
|
|
101
|
+
expect(result).toBe("Success with context");
|
|
102
|
+
});
|
|
103
|
+
it("should handle batch tool and prompt registration", () => {
|
|
104
|
+
const toolRegistry = new Map();
|
|
105
|
+
const promptRegistry = new Map();
|
|
106
|
+
// Register multiple tools
|
|
107
|
+
toolRegistry.set(mockTool.name, mockTool);
|
|
108
|
+
toolRegistry.set("AnotherTool", { ...mockTool, name: "AnotherTool" });
|
|
109
|
+
// Register multiple prompts
|
|
110
|
+
promptRegistry.set(mockPromptEntry.catalogEntry.prompt.name, mockPromptEntry);
|
|
111
|
+
promptRegistry.set("AnotherPrompt", {
|
|
112
|
+
...mockPromptEntry,
|
|
113
|
+
catalogEntry: {
|
|
114
|
+
...mockPromptEntry.catalogEntry,
|
|
115
|
+
prompt: {
|
|
116
|
+
...mockPromptEntry.catalogEntry.prompt,
|
|
117
|
+
name: "AnotherPrompt",
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
expect(toolRegistry.size).toBe(2);
|
|
122
|
+
expect(promptRegistry.size).toBe(2);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { sessionStorage } from "../session.js";
|
|
3
|
+
describe("Session Management", () => {
|
|
4
|
+
it("should maintain session isolation between concurrent contexts", async () => {
|
|
5
|
+
const session1 = { id: "session-1" };
|
|
6
|
+
const session2 = { id: "session-2" };
|
|
7
|
+
const results = [];
|
|
8
|
+
// Run two sessions concurrently
|
|
9
|
+
await Promise.all([
|
|
10
|
+
sessionStorage.run(session1, async () => {
|
|
11
|
+
// Wait a bit to ensure overlap
|
|
12
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
13
|
+
const stored = sessionStorage.getStore();
|
|
14
|
+
if (stored) {
|
|
15
|
+
results.push(stored);
|
|
16
|
+
}
|
|
17
|
+
}),
|
|
18
|
+
sessionStorage.run(session2, async () => {
|
|
19
|
+
const stored = sessionStorage.getStore();
|
|
20
|
+
if (stored) {
|
|
21
|
+
results.push(stored);
|
|
22
|
+
}
|
|
23
|
+
}),
|
|
24
|
+
]);
|
|
25
|
+
// Both sessions should have completed and stored their data
|
|
26
|
+
expect(results).toHaveLength(2);
|
|
27
|
+
// Each session should have its own data
|
|
28
|
+
const ids = results.map((r) => r.id).sort();
|
|
29
|
+
expect(ids).toEqual(["session-1", "session-2"]);
|
|
30
|
+
});
|
|
31
|
+
it("should return undefined when accessed outside of run context", async () => {
|
|
32
|
+
const store = sessionStorage.getStore();
|
|
33
|
+
expect(store).toBeUndefined();
|
|
34
|
+
});
|
|
35
|
+
it("should provide correct session data within run context", async () => {
|
|
36
|
+
const session = {
|
|
37
|
+
id: "test-session-123",
|
|
38
|
+
};
|
|
39
|
+
await sessionStorage.run(session, async () => {
|
|
40
|
+
const stored = sessionStorage.getStore();
|
|
41
|
+
expect(stored).toBeDefined();
|
|
42
|
+
expect(stored?.id).toBe("test-session-123");
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
it("should properly clean up session data after run context completes", async () => {
|
|
46
|
+
const session = { id: "cleanup-456" };
|
|
47
|
+
await sessionStorage.run(session, async () => {
|
|
48
|
+
expect(sessionStorage.getStore()).toBeDefined();
|
|
49
|
+
});
|
|
50
|
+
// After the run completes, store should be undefined
|
|
51
|
+
expect(sessionStorage.getStore()).toBeUndefined();
|
|
52
|
+
});
|
|
53
|
+
it("should handle nested session contexts correctly", async () => {
|
|
54
|
+
const session1 = { id: "outer-1" };
|
|
55
|
+
const session2 = { id: "inner-2" };
|
|
56
|
+
await sessionStorage.run(session1, async () => {
|
|
57
|
+
expect(sessionStorage.getStore()?.id).toBe("outer-1");
|
|
58
|
+
await sessionStorage.run(session2, async () => {
|
|
59
|
+
// Inner context should override outer
|
|
60
|
+
expect(sessionStorage.getStore()?.id).toBe("inner-2");
|
|
61
|
+
});
|
|
62
|
+
// After inner context exits, outer context should be restored
|
|
63
|
+
expect(sessionStorage.getStore()?.id).toBe("outer-1");
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
});
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* CLI entry point for the MCP server.
|
|
4
|
+
*
|
|
5
|
+
* This file keeps runtime side effects limited to a single entrypoint and
|
|
6
|
+
* delegates all application logic to the main module.
|
|
7
|
+
*/
|
|
8
|
+
import { main } from "./index.js";
|
|
9
|
+
main(process.env).catch((error) => {
|
|
10
|
+
console.error("Failed to start MCP server", error);
|
|
11
|
+
process.exitCode = 1;
|
|
12
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createBedrockRuntimeClient, rerankingSupportedRegions, } from "../aws.js";
|
|
3
|
+
describe("aws config", () => {
|
|
4
|
+
it("should export rerankingSupportedRegions as array", () => {
|
|
5
|
+
expect(Array.isArray(rerankingSupportedRegions)).toBe(true);
|
|
6
|
+
expect(rerankingSupportedRegions.length).toBeGreaterThan(0);
|
|
7
|
+
});
|
|
8
|
+
it("should create a Bedrock runtime client", () => {
|
|
9
|
+
const logger = {
|
|
10
|
+
error: vi.fn(),
|
|
11
|
+
};
|
|
12
|
+
const client = createBedrockRuntimeClient("eu-central-1", logger);
|
|
13
|
+
expect(client).toHaveProperty("send");
|
|
14
|
+
});
|
|
15
|
+
});
|