@pagopa/dx-mcpserver 0.0.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.
- package/README.md +104 -0
- package/dist/auth/__tests__/githubAuth.test.js +56 -0
- package/dist/auth/github.js +37 -0
- package/dist/config/__tests__/awsConfig.test.js +24 -0
- package/dist/config/aws.js +26 -0
- package/dist/config/server.js +4 -0
- package/dist/index.js +44 -0
- package/dist/prompts/GenerateTerraformConfiguration.js +29 -0
- package/dist/services/__tests__/bedrock.test.js +44 -0
- package/dist/services/__tests__/resolveToWebsiteUrl.test.js +67 -0
- package/dist/services/bedrock.js +160 -0
- package/dist/tools/QueryPagoPADXDocumentation.js +28 -0
- package/dist/tools/SearchGitHubCode.js +83 -0
- package/dist/tools/__tests__/QueryPagoPADXDocumentation.test.js +22 -0
- package/dist/utils/__tests__/logger.test.js +21 -0
- package/dist/utils/logger.js +9 -0
- package/package.json +49 -0
package/README.md
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# @pagopa/dx-mcpserver
|
|
2
|
+
|
|
3
|
+
> An MCP server that support developers using DX tools.
|
|
4
|
+
|
|
5
|
+
This package contains the implementation of a Model Context Protocol (MCP) server.
|
|
6
|
+
|
|
7
|
+
## Architecture
|
|
8
|
+
|
|
9
|
+
The architecture allows any Model Context Protocol (MCP) compliant client (such as GitHub Copilot) to query the [PagoPA DX technical documentation](https://dx.pagopa.it/) in natural language, receiving contextualized and up-to-date answers.
|
|
10
|
+
|
|
11
|
+
1. **Content Upload**: On each release of the documentation website, Markdown and text files (`.md`, `.txt`) are uploaded to an S3 bucket.
|
|
12
|
+
2. **Indexing**: From there, the documents are processed by **Amazon Bedrock Knowledge Bases**, which handles the embedding and semantic indexing process.
|
|
13
|
+
3. **Vector Storage**: The resulting embeddings are saved in a Vector Bucket (an S3-based vector database), enabling efficient and persistent semantic search.
|
|
14
|
+
4. **Query and Retrieval**: When an MCP client sends a query, an **AWS Lambda** function implementing the MCP Server queries the Knowledge Base to retrieve the most relevant content and returns the response to the client.
|
|
15
|
+
|
|
16
|
+
This approach allows AI agents like Copilot to access the documentation context in a structured way, keeping the orchestration, storage, and semantic retrieval layers separate.
|
|
17
|
+
|
|
18
|
+
## Features
|
|
19
|
+
|
|
20
|
+
The server currently exposes the following capabilities:
|
|
21
|
+
|
|
22
|
+
- **Tools**:
|
|
23
|
+
- `QueryPagoPADXDocumentation`: Queries Amazon Bedrock Knowledge Bases to retrieve relevant content from the [DX documentation](https://dx.pagopa.it/).
|
|
24
|
+
- `SearchGitHubCode`: Searches for code snippets in specified GitHub organization (defaults to pagopa), allowing users to find real-world examples of code usage.
|
|
25
|
+
- **Prompts**:
|
|
26
|
+
- `GenerateTerraformConfiguration`: Guides the generation of Terraform configurations following PagoPA DX best practices.
|
|
27
|
+
|
|
28
|
+
## How to use it
|
|
29
|
+
|
|
30
|
+
This server can be used by any MCP-compliant client.
|
|
31
|
+
|
|
32
|
+
<details>
|
|
33
|
+
<summary><b>VS Code</b></summary>
|
|
34
|
+
|
|
35
|
+
Update your configuration file with the following. See [VS Code MCP docs](https://code.visualstudio.com/docs/copilot/chat/mcp-servers) for more info.
|
|
36
|
+
|
|
37
|
+
#### VS Code Remote Server Connection
|
|
38
|
+
|
|
39
|
+
```json
|
|
40
|
+
{
|
|
41
|
+
"servers": {
|
|
42
|
+
"dx-docs": {
|
|
43
|
+
"url": "https://api.dev.dx.pagopa.it/mcp",
|
|
44
|
+
"type": "http",
|
|
45
|
+
"headers": {
|
|
46
|
+
"x-gh-pat": "${env:GH_PAT}"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
</details>
|
|
54
|
+
|
|
55
|
+
<details>
|
|
56
|
+
<summary><b>GitHub Copilot Coding Agent</b></summary>
|
|
57
|
+
You need to configure it in the repository settings. See [GitHub Copilot MCP docs](https://docs.github.com/en/copilot/how-tos/use-copilot-agents/coding-agent/extend-coding-agent-with-mcp) for more info.
|
|
58
|
+
|
|
59
|
+
1. **Declare the MCP Server**: In the "Copilot" >> "Coding agent" panel of your repository settings, add an MCP Server declaration as follows:
|
|
60
|
+
|
|
61
|
+
```json
|
|
62
|
+
{
|
|
63
|
+
"mcpServers": {
|
|
64
|
+
"pagopa-dx": {
|
|
65
|
+
"url": "https://api.dev.dx.pagopa.it/mcp",
|
|
66
|
+
"type": "http",
|
|
67
|
+
"tools": ["*"],
|
|
68
|
+
"headers": {
|
|
69
|
+
"x-gh-pat": "$COPILOT_MCP_BOT_GH_PAT"
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
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.
|
|
77
|
+
|
|
78
|
+
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.
|
|
79
|
+
|
|
80
|
+
</details>
|
|
81
|
+
|
|
82
|
+
## Development
|
|
83
|
+
|
|
84
|
+
This is a standard TypeScript project. To get started:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
pnpm install
|
|
88
|
+
pnpm --filter @pagopa/dx-mcpserver build
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
You can run the following scripts:
|
|
92
|
+
|
|
93
|
+
- `pnpm --filter @pagopa/dx-mcpserver lint`: Lints the code.
|
|
94
|
+
- `pnpm --filter @pagopa/dx-mcpserver format`: Formats the code.
|
|
95
|
+
- `pnpm --filter @pagopa/dx-mcpserver test`: Runs tests.
|
|
96
|
+
- `pnpm --filter @pagopa/dx-mcpserver typecheck`: Checks types.
|
|
97
|
+
|
|
98
|
+
### Docker
|
|
99
|
+
|
|
100
|
+
To build the Docker container for this application, run the following command from the root of the monorepo:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
docker build -t dx/mcp-server -f ./apps/mcpserver/Dockerfile .
|
|
104
|
+
```
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { Octokit } from "@octokit/rest";
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { logger } from "../../utils/logger.js";
|
|
4
|
+
import * as githubAuth from "../github.js";
|
|
5
|
+
vi.mock("@octokit/rest");
|
|
6
|
+
describe("verifyGithubUser", () => {
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
vi.clearAllMocks();
|
|
9
|
+
process.env.REQUIRED_ORGANIZATIONS = "pagopa";
|
|
10
|
+
});
|
|
11
|
+
it("returns false if no token is provided", async () => {
|
|
12
|
+
const result = await githubAuth.verifyGithubUser("");
|
|
13
|
+
expect(result).toBe(false);
|
|
14
|
+
});
|
|
15
|
+
it("returns false if Octokit throws", async () => {
|
|
16
|
+
vi.mocked(Octokit).mockImplementation(() => ({
|
|
17
|
+
rest: {
|
|
18
|
+
orgs: {
|
|
19
|
+
listForAuthenticatedUser: vi
|
|
20
|
+
.fn()
|
|
21
|
+
.mockRejectedValue(new Error("fail")),
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
}));
|
|
25
|
+
const errorLog = vi.spyOn(logger, "error");
|
|
26
|
+
const result = await githubAuth.verifyGithubUser("token");
|
|
27
|
+
expect(result).toBe(false);
|
|
28
|
+
expect(errorLog).toHaveBeenCalledWith(expect.any(Error), "Error verifying GitHub organization membership:");
|
|
29
|
+
});
|
|
30
|
+
it("returns false if user is not member of required org", async () => {
|
|
31
|
+
vi.mocked(Octokit).mockImplementation(() => ({
|
|
32
|
+
rest: {
|
|
33
|
+
orgs: {
|
|
34
|
+
listForAuthenticatedUser: vi.fn().mockResolvedValue({
|
|
35
|
+
data: [{ login: "otherorg" }],
|
|
36
|
+
}),
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
}));
|
|
40
|
+
const result = await githubAuth.verifyGithubUser("token");
|
|
41
|
+
expect(result).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
it("returns true if user is member of required org", async () => {
|
|
44
|
+
vi.mocked(Octokit).mockImplementation(() => ({
|
|
45
|
+
rest: {
|
|
46
|
+
orgs: {
|
|
47
|
+
listForAuthenticatedUser: vi.fn().mockResolvedValue({
|
|
48
|
+
data: [{ login: "pagopa" }],
|
|
49
|
+
}),
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
}));
|
|
53
|
+
const result = await githubAuth.verifyGithubUser("token");
|
|
54
|
+
expect(result).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Octokit } from "@octokit/rest";
|
|
2
|
+
import { z } from "zod/v4";
|
|
3
|
+
import { logger } from "../utils/logger.js";
|
|
4
|
+
const organizationsSchema = z
|
|
5
|
+
.array(z.string().nonempty())
|
|
6
|
+
.nonempty()
|
|
7
|
+
.transform((orgs) => orgs.map((org) => org.trim()))
|
|
8
|
+
.catch(["pagopa"]);
|
|
9
|
+
const REQUIRED_ORGANIZATIONS = organizationsSchema.parse(process.env.REQUIRED_ORGANIZATIONS);
|
|
10
|
+
/**
|
|
11
|
+
* Verifies that a user, identified by a GitHub personal access token, is a member
|
|
12
|
+
* of at least one of the required GitHub organizations.
|
|
13
|
+
* @param token The user's GitHub personal access token.
|
|
14
|
+
* @returns A boolean indicating whether the user is a member of a required organization.
|
|
15
|
+
*/
|
|
16
|
+
export async function verifyGithubUser(token) {
|
|
17
|
+
if (!token) {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
const octokit = new Octokit({ auth: token });
|
|
21
|
+
try {
|
|
22
|
+
// Fetches the user's organization memberships using Octokit.
|
|
23
|
+
const { data: organizations } = await octokit.rest.orgs.listForAuthenticatedUser();
|
|
24
|
+
const isMember = organizations.some((org) => REQUIRED_ORGANIZATIONS.includes(org.login));
|
|
25
|
+
if (isMember) {
|
|
26
|
+
logger.info(`User is a member of one of the required organizations: ${REQUIRED_ORGANIZATIONS.join(", ")}`);
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
logger.warn(`User is not a member of any of the required organizations: ${REQUIRED_ORGANIZATIONS.join(", ")}`);
|
|
30
|
+
}
|
|
31
|
+
return isMember;
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
logger.error(error, "Error verifying GitHub organization membership:");
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
vi.mock("../../utils/logger", () => ({
|
|
3
|
+
logger: {
|
|
4
|
+
error: vi.fn(),
|
|
5
|
+
info: vi.fn(),
|
|
6
|
+
warn: vi.fn(),
|
|
7
|
+
},
|
|
8
|
+
}));
|
|
9
|
+
import * as awsConfig from "../aws.js";
|
|
10
|
+
describe("aws config", () => {
|
|
11
|
+
it("should export kbRerankingEnabled as boolean", () => {
|
|
12
|
+
expect(typeof awsConfig.kbRerankingEnabled).toBe("boolean");
|
|
13
|
+
});
|
|
14
|
+
it("should export knowledgeBaseId as string", () => {
|
|
15
|
+
expect(typeof awsConfig.knowledgeBaseId).toBe("string");
|
|
16
|
+
});
|
|
17
|
+
it("should export region as string", () => {
|
|
18
|
+
expect(typeof awsConfig.region).toBe("string");
|
|
19
|
+
});
|
|
20
|
+
it("should export kbRuntimeClient as object", () => {
|
|
21
|
+
expect(typeof awsConfig.kbRuntimeClient).toBe("object");
|
|
22
|
+
expect(awsConfig.kbRuntimeClient).toHaveProperty("send");
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { BedrockAgentRuntimeClient } from "@aws-sdk/client-bedrock-agent-runtime";
|
|
2
|
+
import { logger } from "../utils/logger.js";
|
|
3
|
+
// When true, enables reranking for the Bedrock knowledge base queries.
|
|
4
|
+
export const kbRerankingEnabled = (process.env.BEDROCK_KB_RERANKING_ENABLED || "true").trim().toLowerCase() ===
|
|
5
|
+
"true";
|
|
6
|
+
export const knowledgeBaseId = process.env.BEDROCK_KNOWLEDGE_BASE_ID || "";
|
|
7
|
+
logger.info(`Default reranking enabled: ${kbRerankingEnabled} (from BEDROCK_KB_RERANKING_ENABLED)`);
|
|
8
|
+
export const region = process.env.AWS_REGION || "eu-central-1";
|
|
9
|
+
// List of AWS regions that support reranking
|
|
10
|
+
export const rerankingSupportedRegions = [
|
|
11
|
+
"ap-northeast-1",
|
|
12
|
+
"ca-central-1",
|
|
13
|
+
"eu-central-1",
|
|
14
|
+
"us-east-1",
|
|
15
|
+
"us-west-2",
|
|
16
|
+
];
|
|
17
|
+
let kbRuntimeClient;
|
|
18
|
+
try {
|
|
19
|
+
// Initializes the Bedrock Agent Runtime client with the specified region.
|
|
20
|
+
kbRuntimeClient = new BedrockAgentRuntimeClient({ region });
|
|
21
|
+
}
|
|
22
|
+
catch (e) {
|
|
23
|
+
logger.error(e, "Error getting bedrock agent client");
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
export { kbRuntimeClient };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { FastMCP } from "fastmcp";
|
|
2
|
+
import { verifyGithubUser } from "./auth/github.js";
|
|
3
|
+
import { serverInstructions } from "./config/server.js";
|
|
4
|
+
import { GenerateTerraformConfigurationPrompt } from "./prompts/GenerateTerraformConfiguration.js";
|
|
5
|
+
import { QueryPagoPADXDocumentationTool } from "./tools/QueryPagoPADXDocumentation.js";
|
|
6
|
+
import { SearchGitHubCodeTool } from "./tools/SearchGitHubCode.js";
|
|
7
|
+
import { logger } from "./utils/logger.js";
|
|
8
|
+
// Authentication is enabled based on the AUTH_REQUIRED environment variable.
|
|
9
|
+
const server = new FastMCP({
|
|
10
|
+
authenticate: async (request) => {
|
|
11
|
+
const authHeader = request.headers["x-gh-pat"];
|
|
12
|
+
const apiKey = typeof authHeader === "string"
|
|
13
|
+
? authHeader
|
|
14
|
+
: Array.isArray(authHeader)
|
|
15
|
+
? authHeader[0]
|
|
16
|
+
: undefined;
|
|
17
|
+
if (!apiKey || !(await verifyGithubUser(apiKey))) {
|
|
18
|
+
throw new Response(null, {
|
|
19
|
+
status: 401,
|
|
20
|
+
statusText: "Unauthorized",
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
// The returned object is accessible in the `context.session`.
|
|
24
|
+
return {
|
|
25
|
+
id: 1,
|
|
26
|
+
token: apiKey,
|
|
27
|
+
};
|
|
28
|
+
},
|
|
29
|
+
instructions: serverInstructions,
|
|
30
|
+
name: "PagoPA DX Knowledge Retrieval MCP Server",
|
|
31
|
+
version: "0.0.0",
|
|
32
|
+
});
|
|
33
|
+
logger.debug(`Server instructions: \n\n${serverInstructions}`);
|
|
34
|
+
server.addPrompt(GenerateTerraformConfigurationPrompt);
|
|
35
|
+
server.addTool(QueryPagoPADXDocumentationTool);
|
|
36
|
+
server.addTool(SearchGitHubCodeTool);
|
|
37
|
+
// Starts the server in HTTP Stream mode.
|
|
38
|
+
server.start({
|
|
39
|
+
httpStream: {
|
|
40
|
+
port: 8080,
|
|
41
|
+
stateless: true,
|
|
42
|
+
},
|
|
43
|
+
transportType: "httpStream",
|
|
44
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A prompt that generates a Terraform configuration following PagoPA DX best practices.
|
|
3
|
+
* It guides the user to query the knowledge base for information on folder structure,
|
|
4
|
+
* modules, and conventions before generating the code.
|
|
5
|
+
*/
|
|
6
|
+
export const GenerateTerraformConfigurationPrompt = {
|
|
7
|
+
// The arguments for the prompt.
|
|
8
|
+
arguments: [
|
|
9
|
+
{
|
|
10
|
+
description: "Requirements for the Terraform configuration to generate.",
|
|
11
|
+
name: "requirements",
|
|
12
|
+
required: false,
|
|
13
|
+
},
|
|
14
|
+
],
|
|
15
|
+
// The description of the prompt.
|
|
16
|
+
description: "Generates a Terraform configuration following PagoPA DX best practices.",
|
|
17
|
+
// The function that loads the prompt.
|
|
18
|
+
load: async (args) => `To generate a Terraform configuration for ${args.requirements || "any requirement"}, you must follow the PagoPA DX best practices.
|
|
19
|
+
|
|
20
|
+
You can find information about the "infrastructure folder structure", "Using DX terraform modules", "Using DX terraform provider" and other conventions by using the \`QueryPagoPADXDocumentation\` tool. For example, you can ask "what is the infrastructure folder structure?", but you must ask for the other information as well.
|
|
21
|
+
It's suggested to call the \`QueryPagoPADXDocumentation\` tool multiple times to gather all the necessary information using simple and scoped questions.
|
|
22
|
+
|
|
23
|
+
When generating the configuration, remember to:
|
|
24
|
+
1. Use existing Terraform modules from the \`pagopa-dx\` namespace whenever possible. You can search for them using the \`searchModules\` tool.
|
|
25
|
+
2. Strictly follow the correct folder structure for infrastructure resources.
|
|
26
|
+
3. Generate HCL code that is clean, readable, and well-documented.`,
|
|
27
|
+
// The name of the prompt.
|
|
28
|
+
name: "generate-terraform-configuration",
|
|
29
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
vi.mock("../../utils/logger", () => ({
|
|
3
|
+
logger: {
|
|
4
|
+
error: vi.fn(),
|
|
5
|
+
info: vi.fn(),
|
|
6
|
+
warn: vi.fn(),
|
|
7
|
+
},
|
|
8
|
+
}));
|
|
9
|
+
import { queryKnowledgeBase } from "../bedrock.js";
|
|
10
|
+
describe("queryKnowledgeBase", () => {
|
|
11
|
+
it("should skip images in results", async () => {
|
|
12
|
+
const mockClient = {
|
|
13
|
+
config: {
|
|
14
|
+
apiVersion: "2023-11-20",
|
|
15
|
+
region: async () => "eu-central-1",
|
|
16
|
+
requestHandler: { handle: vi.fn() },
|
|
17
|
+
},
|
|
18
|
+
destroy: vi.fn(),
|
|
19
|
+
middlewareStack: {},
|
|
20
|
+
send: vi.fn().mockResolvedValue({
|
|
21
|
+
retrievalResults: [
|
|
22
|
+
{ content: { text: "doc1", type: "TEXT" } },
|
|
23
|
+
{ content: { type: "IMAGE" } },
|
|
24
|
+
],
|
|
25
|
+
}),
|
|
26
|
+
};
|
|
27
|
+
const result = await queryKnowledgeBase("kbId", "query", mockClient, 2, false);
|
|
28
|
+
expect(result).toContain("doc1");
|
|
29
|
+
});
|
|
30
|
+
it("should warn if reranking is not supported in region", async () => {
|
|
31
|
+
const mockClient = {
|
|
32
|
+
config: {
|
|
33
|
+
apiVersion: "2023-11-20",
|
|
34
|
+
region: async () => "unsupported-region",
|
|
35
|
+
requestHandler: { handle: vi.fn() },
|
|
36
|
+
},
|
|
37
|
+
destroy: vi.fn(),
|
|
38
|
+
middlewareStack: {},
|
|
39
|
+
send: vi.fn().mockResolvedValue({ retrievalResults: [] }),
|
|
40
|
+
};
|
|
41
|
+
const result = await queryKnowledgeBase("kbId", "query", mockClient, 2, true);
|
|
42
|
+
expect(typeof result).toBe("string");
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
it("returns dx.pagopa.it/llms-full.txt for llms-full.txt", () => {
|
|
2
|
+
const location = {
|
|
3
|
+
s3Location: {
|
|
4
|
+
uri: "s3://bucket/llms-full.txt",
|
|
5
|
+
},
|
|
6
|
+
type: "S3",
|
|
7
|
+
};
|
|
8
|
+
const result = resolveToWebsiteUrl(location);
|
|
9
|
+
expect(result?.webLocation?.url).toBe("https://dx.pagopa.it/llms-full.txt");
|
|
10
|
+
});
|
|
11
|
+
it("returns dx.pagopa.it/llms.txt for llms.txt", () => {
|
|
12
|
+
const location = {
|
|
13
|
+
s3Location: {
|
|
14
|
+
uri: "s3://bucket/llms.txt",
|
|
15
|
+
},
|
|
16
|
+
type: "S3",
|
|
17
|
+
};
|
|
18
|
+
const result = resolveToWebsiteUrl(location);
|
|
19
|
+
expect(result?.webLocation?.url).toBe("https://dx.pagopa.it/llms.txt");
|
|
20
|
+
});
|
|
21
|
+
it("returns dx.pagopa.it/blog/ for keys starting with /blog/", () => {
|
|
22
|
+
const location = {
|
|
23
|
+
s3Location: {
|
|
24
|
+
uri: "s3://bucket/blog/some-post.md",
|
|
25
|
+
},
|
|
26
|
+
type: "S3",
|
|
27
|
+
};
|
|
28
|
+
const result = resolveToWebsiteUrl(location);
|
|
29
|
+
expect(result?.webLocation?.url).toBe("https://dx.pagopa.it/blog/");
|
|
30
|
+
});
|
|
31
|
+
import { describe, expect, it } from "vitest";
|
|
32
|
+
import { resolveToWebsiteUrl } from "../bedrock.js";
|
|
33
|
+
describe("resolveToWebsiteUrl", () => {
|
|
34
|
+
it("converte una chiave S3 .md in url dx.pagopa.it/docs", () => {
|
|
35
|
+
const location = {
|
|
36
|
+
s3Location: {
|
|
37
|
+
uri: "s3://bucket/azure/iam.md",
|
|
38
|
+
},
|
|
39
|
+
type: "S3",
|
|
40
|
+
};
|
|
41
|
+
const result = resolveToWebsiteUrl(location);
|
|
42
|
+
expect(result?.webLocation?.url).toBe("https://dx.pagopa.it/docs/azure/iam");
|
|
43
|
+
});
|
|
44
|
+
it("converte una chiave S3 index.md in url dx.pagopa.it/docs con slash finale", () => {
|
|
45
|
+
const location = {
|
|
46
|
+
s3Location: {
|
|
47
|
+
uri: "s3://bucket/pipelines/index.md",
|
|
48
|
+
},
|
|
49
|
+
type: "S3",
|
|
50
|
+
};
|
|
51
|
+
const result = resolveToWebsiteUrl(location);
|
|
52
|
+
expect(result?.webLocation?.url).toBe("https://dx.pagopa.it/docs/pipelines/");
|
|
53
|
+
});
|
|
54
|
+
it("ignora location non S3", () => {
|
|
55
|
+
const location = {
|
|
56
|
+
type: "WEB",
|
|
57
|
+
webLocation: {
|
|
58
|
+
url: "https://example.com",
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
const result = resolveToWebsiteUrl(location);
|
|
62
|
+
expect(result?.webLocation?.url).toBe("https://example.com");
|
|
63
|
+
});
|
|
64
|
+
it("restituisce undefined se location è undefined", () => {
|
|
65
|
+
expect(resolveToWebsiteUrl(undefined)).toBeUndefined();
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { RetrieveCommand, } from "@aws-sdk/client-bedrock-agent-runtime";
|
|
2
|
+
import { rerankingSupportedRegions } from "../config/aws.js";
|
|
3
|
+
import { logger } from "../utils/logger.js";
|
|
4
|
+
/**
|
|
5
|
+
* Queries a Bedrock knowledge base with a given query, handling reranking and result serialization.
|
|
6
|
+
* This method interacts with the AWS Bedrock service to retrieve knowledge base results.
|
|
7
|
+
* It supports reranking of results in specific AWS regions using predefined models.
|
|
8
|
+
*
|
|
9
|
+
* @param knowledgeBaseId The ID of the knowledge base to query.
|
|
10
|
+
* @param query The natural language query.
|
|
11
|
+
* @param kbAgentClient The Bedrock Agent Runtime client.
|
|
12
|
+
* @param numberOfResults The maximum number of results to return (default: 5).
|
|
13
|
+
* @param reranking Whether to enable reranking of the results (default: false).
|
|
14
|
+
* @param rerankingModelName The reranking model to use (default: AMAZON).
|
|
15
|
+
* @returns A serialized string of the query results.
|
|
16
|
+
*/
|
|
17
|
+
export async function queryKnowledgeBase(knowledgeBaseId, query, kbAgentClient, numberOfResults = 5, reranking = false, rerankingModelName = "AMAZON") {
|
|
18
|
+
const clientRegion = await kbAgentClient.config.region();
|
|
19
|
+
let rerankingEnabled = reranking;
|
|
20
|
+
// Reranking is only supported in specific AWS regions.
|
|
21
|
+
if (reranking && !rerankingSupportedRegions.includes(clientRegion)) {
|
|
22
|
+
logger.warn(`Reranking is not supported in region ${clientRegion}`);
|
|
23
|
+
rerankingEnabled = false;
|
|
24
|
+
}
|
|
25
|
+
const retrieveRequest = {
|
|
26
|
+
vectorSearchConfiguration: {
|
|
27
|
+
numberOfResults,
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
if (rerankingEnabled && retrieveRequest.vectorSearchConfiguration) {
|
|
31
|
+
const modelNameMapping = {
|
|
32
|
+
AMAZON: "amazon.rerank-v1:0",
|
|
33
|
+
COHERE: "cohere.rerank-v3-5:0",
|
|
34
|
+
};
|
|
35
|
+
retrieveRequest.vectorSearchConfiguration.rerankingConfiguration = {
|
|
36
|
+
bedrockRerankingConfiguration: {
|
|
37
|
+
modelConfiguration: {
|
|
38
|
+
modelArn: `arn:aws:bedrock:${clientRegion}::foundation-model/${modelNameMapping[rerankingModelName]}`,
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
type: "BEDROCK_RERANKING_MODEL",
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
const command = new RetrieveCommand({
|
|
45
|
+
knowledgeBaseId,
|
|
46
|
+
retrievalConfiguration: retrieveRequest,
|
|
47
|
+
retrievalQuery: { text: query },
|
|
48
|
+
});
|
|
49
|
+
const response = await kbAgentClient.send(command);
|
|
50
|
+
const results = response.retrievalResults || [];
|
|
51
|
+
const documents = [];
|
|
52
|
+
for (const result of results) {
|
|
53
|
+
if (result.content?.type === "IMAGE") {
|
|
54
|
+
logger.warn("Images are not supported at this time. Skipping...");
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
else if (result.content?.text) {
|
|
58
|
+
documents.push({
|
|
59
|
+
content: result.content.text,
|
|
60
|
+
location: resolveToWebsiteUrl(result.location),
|
|
61
|
+
score: result.score || -1.0,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return serializeResults(documents);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Resolves an S3 location from a knowledge base result to a public website URL.
|
|
69
|
+
* This method converts S3 URIs to publicly accessible URLs based on specific rules.
|
|
70
|
+
* If the location is not an S3 URI, it returns the original location.
|
|
71
|
+
*
|
|
72
|
+
* @param location The original location object from the retrieval result.
|
|
73
|
+
* @returns A new location object with a `WEB` type and a URL, or the original location if no conversion is needed.
|
|
74
|
+
*/
|
|
75
|
+
export function resolveToWebsiteUrl(location) {
|
|
76
|
+
if (!location) {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
// If the location is an S3 URI, convert it to a dx.pagopa.it URL.
|
|
80
|
+
if (typeof location === "object" &&
|
|
81
|
+
location.type === "S3" &&
|
|
82
|
+
location.s3Location?.uri) {
|
|
83
|
+
// The uri format is: s3://bucket/key
|
|
84
|
+
const match = location.s3Location.uri.match(/^s3:\/\/(?:[^/]+)\/(.+)$/);
|
|
85
|
+
if (match) {
|
|
86
|
+
const key = match[1];
|
|
87
|
+
let url = "";
|
|
88
|
+
// Special case: llms-full.txt or llms.txt should be returned as https://dx.pagopa.it/<file>
|
|
89
|
+
if (key === "llms-full.txt" || key === "llms.txt") {
|
|
90
|
+
url = `https://dx.pagopa.it/${key}`;
|
|
91
|
+
}
|
|
92
|
+
// Special case: if key starts with blog/, return https://dx.pagopa.it/blog/
|
|
93
|
+
else if (key.startsWith("blog/")) {
|
|
94
|
+
url = "https://dx.pagopa.it/blog/";
|
|
95
|
+
}
|
|
96
|
+
// If the key ends with /index.md, return the directory with trailing slash
|
|
97
|
+
else if (key.endsWith("/index.md")) {
|
|
98
|
+
url = `https://dx.pagopa.it/docs/${key.replace(/\/index\.md$/, "")}/`;
|
|
99
|
+
}
|
|
100
|
+
// Otherwise, remove .md and return the documentation path
|
|
101
|
+
else {
|
|
102
|
+
url = `https://dx.pagopa.it/docs/${key.replace(/\.md$/, "")}`;
|
|
103
|
+
}
|
|
104
|
+
// Return a RetrievalResultLocation object of type WEB with the computed URL
|
|
105
|
+
return {
|
|
106
|
+
type: "WEB",
|
|
107
|
+
webLocation: {
|
|
108
|
+
url,
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
// If not S3, return the original location (e.g. WEB, SHAREPOINT, etc.)
|
|
115
|
+
return location;
|
|
116
|
+
}
|
|
117
|
+
// If unable to determine the URL, return undefined
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Serializes an array of knowledge base query results into a formatted string.
|
|
122
|
+
* This method formats the results for better readability, including content, location, and score.
|
|
123
|
+
*
|
|
124
|
+
* Example:
|
|
125
|
+
* Input:
|
|
126
|
+
* ```json
|
|
127
|
+
* [
|
|
128
|
+
* { "content": "Result 1 content", "location": { "webLocation": { "url": "https://example.com" } }, "score": 0.95 },
|
|
129
|
+
* { "content": "Result 2 content", "location": null, "score": 0.85 }
|
|
130
|
+
* ]
|
|
131
|
+
* ```
|
|
132
|
+
*
|
|
133
|
+
* Output:
|
|
134
|
+
* ```
|
|
135
|
+
* Result 1 (Score: 0.9500):
|
|
136
|
+
* Result 1 content
|
|
137
|
+
* Location: https://example.com
|
|
138
|
+
*
|
|
139
|
+
* Result 2 (Score: 0.8500):
|
|
140
|
+
* Result 2 content
|
|
141
|
+
* Location: undefined
|
|
142
|
+
* ```
|
|
143
|
+
*/
|
|
144
|
+
function serializeResults(results) {
|
|
145
|
+
return results
|
|
146
|
+
.map((result, index) => {
|
|
147
|
+
let locationStr = "";
|
|
148
|
+
if (result.location &&
|
|
149
|
+
typeof result.location === "object" &&
|
|
150
|
+
"webLocation" in result.location &&
|
|
151
|
+
result.location.webLocation?.url) {
|
|
152
|
+
locationStr = result.location.webLocation.url;
|
|
153
|
+
}
|
|
154
|
+
else if (result.location) {
|
|
155
|
+
locationStr = JSON.stringify(result.location);
|
|
156
|
+
}
|
|
157
|
+
return `Result ${index + 1} (Score: ${result.score.toFixed(4)}):\n${result.content}\nLocation: ${locationStr}\n`;
|
|
158
|
+
})
|
|
159
|
+
.join("\n\n");
|
|
160
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { kbRerankingEnabled, kbRuntimeClient, knowledgeBaseId, } from "../config/aws.js";
|
|
3
|
+
import { queryKnowledgeBase } from "../services/bedrock.js";
|
|
4
|
+
/**
|
|
5
|
+
* A tool that provides access to the complete Terraform documentation for PagoPA Dx.
|
|
6
|
+
* It uses a Bedrock knowledge base to answer queries about Terraform modules and best practices.
|
|
7
|
+
*/
|
|
8
|
+
export const QueryPagoPADXDocumentationTool = {
|
|
9
|
+
annotations: {
|
|
10
|
+
title: "Query PagoPA DX Terraform documentation",
|
|
11
|
+
},
|
|
12
|
+
description: `This tool provides access to the complete Terraform documentation for PagoPA Dx.
|
|
13
|
+
Use this knowledge base to generate or review Terraform configurations aligned with the official PagoPA Dx module conventions.
|
|
14
|
+
All prompts and questions should be written in English, so that the tool responds using English resource and variable names.
|
|
15
|
+
The tool should be used to explain, guide, or suggest Terraform usage based on verified module documentation and internal best practices.
|
|
16
|
+
Use only modules from the pagopa-dx namespace. To get terraform modules descriptions, input/output variables and examples, use the \`searchModules\` tool.
|
|
17
|
+
`,
|
|
18
|
+
execute: async (args) => {
|
|
19
|
+
const result = await queryKnowledgeBase(knowledgeBaseId, args.query, kbRuntimeClient, undefined, kbRerankingEnabled);
|
|
20
|
+
return result;
|
|
21
|
+
},
|
|
22
|
+
name: "QueryPagoPADXTerraformDocumentation",
|
|
23
|
+
parameters: z.object({
|
|
24
|
+
query: z
|
|
25
|
+
.string()
|
|
26
|
+
.describe("A natural language query in English used to search the DX documentation for relevant information."),
|
|
27
|
+
}),
|
|
28
|
+
};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { Octokit } from "@octokit/rest";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { logger } from "../utils/logger.js";
|
|
4
|
+
const defaultOrg = process.env.GITHUB_SEARCH_ORG || "pagopa";
|
|
5
|
+
/**
|
|
6
|
+
* A tool that searches for code in a GitHub organization.
|
|
7
|
+
* Useful for finding examples of Terraform module usage or specific code patterns.
|
|
8
|
+
*/
|
|
9
|
+
export const SearchGitHubCodeTool = {
|
|
10
|
+
annotations: {
|
|
11
|
+
title: "Search GitHub organization code",
|
|
12
|
+
},
|
|
13
|
+
description: `Search for code in a GitHub organization (defaults to pagopa).
|
|
14
|
+
Use this to find examples of specific code patterns, such as Terraform module usage.
|
|
15
|
+
For example, search for "pagopa-dx/azure-function-app/azurerm" to find examples of the azure-function-app module usage.
|
|
16
|
+
Returns file contents matching the search query.`,
|
|
17
|
+
execute: async (args, context) => {
|
|
18
|
+
const org = defaultOrg;
|
|
19
|
+
const token = context.session?.token;
|
|
20
|
+
if (!token) {
|
|
21
|
+
throw new Error("GitHub token not available in session");
|
|
22
|
+
}
|
|
23
|
+
const octokit = new Octokit({ auth: token });
|
|
24
|
+
try {
|
|
25
|
+
const extensionFilter = args.extension
|
|
26
|
+
? ` extension:${args.extension}`
|
|
27
|
+
: "";
|
|
28
|
+
const searchQuery = `${args.query} org:${org}${extensionFilter}`;
|
|
29
|
+
logger.info(`Searching GitHub: ${searchQuery}`);
|
|
30
|
+
const { data } = await octokit.rest.search.code({
|
|
31
|
+
per_page: 10,
|
|
32
|
+
q: searchQuery,
|
|
33
|
+
});
|
|
34
|
+
if (data.items.length === 0) {
|
|
35
|
+
return JSON.stringify({ message: "No results found", results: [] });
|
|
36
|
+
}
|
|
37
|
+
const results = await Promise.all(data.items.map(async (item) => {
|
|
38
|
+
try {
|
|
39
|
+
const { data: fileContent } = await octokit.rest.repos.getContent({
|
|
40
|
+
owner: org,
|
|
41
|
+
path: item.path,
|
|
42
|
+
repo: item.repository.name,
|
|
43
|
+
});
|
|
44
|
+
if ("content" in fileContent) {
|
|
45
|
+
return {
|
|
46
|
+
content: Buffer.from(fileContent.content, "base64").toString("utf-8"),
|
|
47
|
+
path: item.path,
|
|
48
|
+
repository: item.repository.full_name,
|
|
49
|
+
url: item.html_url,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
logger.error(error, `Error fetching file ${item.path}`);
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}));
|
|
59
|
+
const validResults = results.filter((r) => r !== null);
|
|
60
|
+
return JSON.stringify({
|
|
61
|
+
organization: org,
|
|
62
|
+
query: args.query,
|
|
63
|
+
results: validResults,
|
|
64
|
+
returned_results: validResults.length,
|
|
65
|
+
total_results: data.total_count,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
logger.error(error, "Error searching GitHub code");
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
name: "SearchGitHubCode",
|
|
74
|
+
parameters: z.object({
|
|
75
|
+
extension: z
|
|
76
|
+
.string()
|
|
77
|
+
.optional()
|
|
78
|
+
.describe('File extension to filter results (e.g., "tf" for Terraform files, "py" for Python files). For example, you can use "tf" to find Terraform module usage examples.'),
|
|
79
|
+
query: z
|
|
80
|
+
.string()
|
|
81
|
+
.describe('Code search query (e.g., "pagopa-dx/azure-function-app/azurerm" to find Terraform module usage examples)'),
|
|
82
|
+
}),
|
|
83
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
vi.mock("../../services/bedrock", () => ({
|
|
3
|
+
queryKnowledgeBase: vi.fn().mockResolvedValue("mocked result"),
|
|
4
|
+
}));
|
|
5
|
+
vi.mock("../../config/aws", () => ({
|
|
6
|
+
kbRerankingEnabled: false,
|
|
7
|
+
kbRuntimeClient: "mockClient",
|
|
8
|
+
knowledgeBaseId: "mockKbId",
|
|
9
|
+
}));
|
|
10
|
+
import { QueryPagoPADXDocumentationTool } from "../QueryPagoPADXDocumentation.js";
|
|
11
|
+
describe("QueryPagoPADXDocumentationTool", () => {
|
|
12
|
+
it("should return results from the knowledge base", async () => {
|
|
13
|
+
const args = { number_of_results: 3, query: "test query" };
|
|
14
|
+
const result = await QueryPagoPADXDocumentationTool.execute(args);
|
|
15
|
+
expect(result).toBe("mocked result");
|
|
16
|
+
});
|
|
17
|
+
it("should use default number_of_results if not provided", async () => {
|
|
18
|
+
const args = { query: "test query" };
|
|
19
|
+
const result = await QueryPagoPADXDocumentationTool.execute(args);
|
|
20
|
+
expect(result).toBe("mocked result");
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
vi.mock("../logger", () => ({
|
|
3
|
+
logger: {
|
|
4
|
+
error: vi.fn(),
|
|
5
|
+
info: vi.fn(),
|
|
6
|
+
warn: vi.fn(),
|
|
7
|
+
},
|
|
8
|
+
}));
|
|
9
|
+
import { logger } from "../logger.js";
|
|
10
|
+
describe("logger", () => {
|
|
11
|
+
it("should have info, warn, error methods", () => {
|
|
12
|
+
expect(typeof logger.info).toBe("function");
|
|
13
|
+
expect(typeof logger.warn).toBe("function");
|
|
14
|
+
expect(typeof logger.error).toBe("function");
|
|
15
|
+
});
|
|
16
|
+
it("should not throw when calling info, warn, error", () => {
|
|
17
|
+
expect(() => logger.info("test info")).not.toThrow();
|
|
18
|
+
expect(() => logger.warn("test warn")).not.toThrow();
|
|
19
|
+
expect(() => logger.error("test error")).not.toThrow();
|
|
20
|
+
});
|
|
21
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import pino from "pino";
|
|
2
|
+
import { pinoLambdaDestination } from "pino-lambda";
|
|
3
|
+
// Creates a Pino logger instance configured for Lambda environments.
|
|
4
|
+
const destination = pinoLambdaDestination();
|
|
5
|
+
export const logger = pino({
|
|
6
|
+
transport: {
|
|
7
|
+
target: "pino-pretty",
|
|
8
|
+
},
|
|
9
|
+
}, destination);
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pagopa/dx-mcpserver",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "An MCP server that support developers using DX tools.",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/pagopa/dx.git",
|
|
9
|
+
"directory": "apps/mcpserver"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"DX",
|
|
13
|
+
"MCP"
|
|
14
|
+
],
|
|
15
|
+
"files": [
|
|
16
|
+
"dist"
|
|
17
|
+
],
|
|
18
|
+
"bin": {
|
|
19
|
+
"dx": "./dist/index.js"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@aws-sdk/client-bedrock-agent-runtime": "^3.583.0",
|
|
23
|
+
"@octokit/rest": "^22.0.0",
|
|
24
|
+
"axios": "^1.12.2",
|
|
25
|
+
"fastmcp": "^3.19.1",
|
|
26
|
+
"pino": "^9.1.0",
|
|
27
|
+
"pino-lambda": "^4.4.1",
|
|
28
|
+
"pino-pretty": "^13.1.1",
|
|
29
|
+
"zod": "^3.25.76"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@types/node": "^22.16.2",
|
|
33
|
+
"@vitest/coverage-v8": "^3.2.4",
|
|
34
|
+
"eslint": "^9.30.0",
|
|
35
|
+
"typescript": "~5.8.3",
|
|
36
|
+
"vitest": "^3.2.4",
|
|
37
|
+
"@pagopa/eslint-config": "^5.1.0"
|
|
38
|
+
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"build": "tsc",
|
|
41
|
+
"lint": "eslint --fix src",
|
|
42
|
+
"lint:check": "eslint src",
|
|
43
|
+
"format": "prettier --write .",
|
|
44
|
+
"format:check": "prettier --check .",
|
|
45
|
+
"typecheck": "tsc --noEmit",
|
|
46
|
+
"test": "vitest run",
|
|
47
|
+
"test:coverage": "vitest run --coverage"
|
|
48
|
+
}
|
|
49
|
+
}
|