@pagopa/dx-mcpserver 0.0.12 → 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.
- 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
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { API_TIMEOUT, DEFAULT_PAGE_SIZE, MAX_RESULTS } from "../constants.js";
|
|
3
|
+
describe("Server Constants", () => {
|
|
4
|
+
describe("API_TIMEOUT", () => {
|
|
5
|
+
it("should be a positive number", () => {
|
|
6
|
+
expect(API_TIMEOUT).toBeGreaterThan(0);
|
|
7
|
+
});
|
|
8
|
+
it("should be 30 seconds (30000ms)", () => {
|
|
9
|
+
expect(API_TIMEOUT).toBe(30000);
|
|
10
|
+
});
|
|
11
|
+
});
|
|
12
|
+
describe("MAX_RESULTS", () => {
|
|
13
|
+
it("should be a positive number", () => {
|
|
14
|
+
expect(MAX_RESULTS).toBeGreaterThan(0);
|
|
15
|
+
});
|
|
16
|
+
it("should be 100", () => {
|
|
17
|
+
expect(MAX_RESULTS).toBe(100);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
describe("DEFAULT_PAGE_SIZE", () => {
|
|
21
|
+
it("should be a positive number", () => {
|
|
22
|
+
expect(DEFAULT_PAGE_SIZE).toBeGreaterThan(0);
|
|
23
|
+
});
|
|
24
|
+
it("should be 20", () => {
|
|
25
|
+
expect(DEFAULT_PAGE_SIZE).toBe(20);
|
|
26
|
+
});
|
|
27
|
+
it("should be less than MAX_RESULTS", () => {
|
|
28
|
+
expect(DEFAULT_PAGE_SIZE).toBeLessThan(MAX_RESULTS);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
});
|
package/dist/config/aws.js
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AWS configuration helpers for the MCP server.
|
|
3
|
+
*
|
|
4
|
+
* This module is intentionally side-effect free to keep initialization
|
|
5
|
+
* controlled by the entrypoint.
|
|
6
|
+
*/
|
|
1
7
|
import { BedrockAgentRuntimeClient } from "@aws-sdk/client-bedrock-agent-runtime";
|
|
2
|
-
import { getLogger } from "@logtape/logtape";
|
|
3
|
-
const logger = getLogger(["mcpserver", "aws-config"]);
|
|
4
|
-
// When true, enables reranking for the Bedrock knowledge base queries.
|
|
5
|
-
export const kbRerankingEnabled = (process.env.BEDROCK_KB_RERANKING_ENABLED || "true").trim().toLowerCase() ===
|
|
6
|
-
"true";
|
|
7
|
-
export const knowledgeBaseId = process.env.BEDROCK_KNOWLEDGE_BASE_ID || "";
|
|
8
|
-
logger.debug(`Default reranking enabled: ${kbRerankingEnabled} (from BEDROCK_KB_RERANKING_ENABLED)`);
|
|
9
|
-
export const region = process.env.AWS_REGION || "eu-central-1";
|
|
10
8
|
// List of AWS regions that support reranking
|
|
11
9
|
export const rerankingSupportedRegions = [
|
|
12
10
|
"ap-northeast-1",
|
|
@@ -15,13 +13,12 @@ export const rerankingSupportedRegions = [
|
|
|
15
13
|
"us-east-1",
|
|
16
14
|
"us-west-2",
|
|
17
15
|
];
|
|
18
|
-
|
|
19
|
-
try {
|
|
20
|
-
|
|
21
|
-
|
|
16
|
+
export function createBedrockRuntimeClient(region, logger) {
|
|
17
|
+
try {
|
|
18
|
+
return new BedrockAgentRuntimeClient({ region });
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
logger.error("Error getting bedrock agent client", { error });
|
|
22
|
+
throw error;
|
|
23
|
+
}
|
|
22
24
|
}
|
|
23
|
-
catch (e) {
|
|
24
|
-
logger.error("Error getting bedrock agent client", { error: e });
|
|
25
|
-
process.exit(1);
|
|
26
|
-
}
|
|
27
|
-
export { kbRuntimeClient };
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server constants and configuration values
|
|
3
|
+
*/
|
|
4
|
+
/** Timeout for API requests in milliseconds */
|
|
5
|
+
export const API_TIMEOUT = 30000;
|
|
6
|
+
/** Maximum number of results to return */
|
|
7
|
+
export const MAX_RESULTS = 100;
|
|
8
|
+
/** Default page size for paginated results */
|
|
9
|
+
export const DEFAULT_PAGE_SIZE = 20;
|
package/dist/config/logging.js
CHANGED
|
@@ -16,31 +16,27 @@
|
|
|
16
16
|
* logger.info("Message");
|
|
17
17
|
*/
|
|
18
18
|
import { configure, getConsoleSink } from "@logtape/logtape";
|
|
19
|
-
import
|
|
19
|
+
import * as z from "zod";
|
|
20
20
|
/**
|
|
21
21
|
* Zod schema for validating log levels.
|
|
22
22
|
* Based on LogTape's LogLevel type.
|
|
23
23
|
*/
|
|
24
24
|
const DEFAULT_LOG_LEVEL = "info";
|
|
25
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
25
26
|
const logLevelSchema = z
|
|
26
|
-
.enum(["debug", "info", "warning", "
|
|
27
|
+
.enum(["error", "trace", "debug", "info", "warning", "fatal"])
|
|
27
28
|
.catch(DEFAULT_LOG_LEVEL);
|
|
28
|
-
export async function configureLogging() {
|
|
29
|
-
const logLevel = logLevelSchema.parse(process.env.LOG_LEVEL);
|
|
30
|
-
if (logLevel !== process.env.LOG_LEVEL) {
|
|
31
|
-
// Use console.warn for this early logging before LogTape is configured
|
|
32
|
-
console.warn(`Invalid log level: ${process.env.LOG_LEVEL}. Using ${DEFAULT_LOG_LEVEL}`);
|
|
33
|
-
}
|
|
29
|
+
export async function configureLogging(logLevelEnv) {
|
|
34
30
|
await configure({
|
|
35
31
|
loggers: [
|
|
36
32
|
{
|
|
37
33
|
category: ["mcpserver"],
|
|
38
|
-
lowestLevel:
|
|
34
|
+
lowestLevel: logLevelEnv,
|
|
39
35
|
sinks: ["console"],
|
|
40
36
|
},
|
|
41
37
|
{
|
|
42
38
|
category: ["mcp-prompts"],
|
|
43
|
-
lowestLevel:
|
|
39
|
+
lowestLevel: logLevelEnv,
|
|
44
40
|
sinks: ["console"],
|
|
45
41
|
},
|
|
46
42
|
{
|
|
@@ -12,12 +12,20 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { getLogger } from "@logtape/logtape";
|
|
14
14
|
import { initAzureMonitor } from "@pagopa/azure-tracing/azure-monitor";
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
export function configureAzureMonitoring(config) {
|
|
16
|
+
const logger = getLogger(["mcpserver", "monitoring"]);
|
|
17
17
|
try {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
if (!config.connectionString) {
|
|
19
|
+
logger.info("Azure Application Insights connection string not provided. Monitoring disabled.");
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
initAzureMonitor([], {
|
|
23
|
+
azureMonitorExporterOptions: {
|
|
24
|
+
connectionString: config.connectionString,
|
|
25
|
+
},
|
|
26
|
+
enableLiveMetrics: true,
|
|
27
|
+
samplingRatio: config.samplingRatio,
|
|
28
|
+
});
|
|
21
29
|
logger.info("Azure Application Insights monitoring configured successfully");
|
|
22
30
|
}
|
|
23
31
|
catch (error) {
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { formatZodIssues } from "./utils/errors.js";
|
|
3
|
+
import { normalizeBoolean } from "./utils/normalize-boolean.js";
|
|
4
|
+
export const DEFAULT_PORT = 8080;
|
|
5
|
+
const logLevelSchema = z
|
|
6
|
+
.enum(["debug", "info", "warning", "error"])
|
|
7
|
+
.default("info");
|
|
8
|
+
export const envSchema = z.object({
|
|
9
|
+
APPINSIGHTS_SAMPLING_PERCENTAGE: z.coerce.number().min(0).max(100).optional(),
|
|
10
|
+
APPLICATIONINSIGHTS_CONNECTION_STRING: z.string().optional(),
|
|
11
|
+
AWS_REGION: z.string().optional(),
|
|
12
|
+
BEDROCK_KB_RERANKING_ENABLED: z.string().optional(),
|
|
13
|
+
BEDROCK_KNOWLEDGE_BASE_ID: z.string().optional(),
|
|
14
|
+
BEDROCK_MODEL_ARN: z.string().optional(),
|
|
15
|
+
GITHUB_SEARCH_ORG: z.string().optional(),
|
|
16
|
+
LOG_LEVEL: logLevelSchema,
|
|
17
|
+
PORT: z.coerce.number().int().positive().optional(),
|
|
18
|
+
REQUIRED_ORGANIZATIONS: z.string().optional(),
|
|
19
|
+
});
|
|
20
|
+
/**
|
|
21
|
+
* Loads and validates the application configuration from environment variables.
|
|
22
|
+
*
|
|
23
|
+
* @param env - The environment variables object (usually process.env)
|
|
24
|
+
* @returns The validated application configuration
|
|
25
|
+
* @throws Error if environment variables fail validation
|
|
26
|
+
*/
|
|
27
|
+
export function loadConfig(env) {
|
|
28
|
+
const parsedEnv = envSchema.safeParse(env);
|
|
29
|
+
if (!parsedEnv.success) {
|
|
30
|
+
throw new Error(`Invalid environment variables: ${formatZodIssues(parsedEnv.error.issues)}`);
|
|
31
|
+
}
|
|
32
|
+
const rawEnv = parsedEnv.data;
|
|
33
|
+
const port = rawEnv.PORT ?? DEFAULT_PORT;
|
|
34
|
+
const rerankingEnabled = normalizeBoolean(rawEnv.BEDROCK_KB_RERANKING_ENABLED, true);
|
|
35
|
+
return {
|
|
36
|
+
aws: {
|
|
37
|
+
knowledgeBaseId: rawEnv.BEDROCK_KNOWLEDGE_BASE_ID ?? "",
|
|
38
|
+
modelArn: rawEnv.BEDROCK_MODEL_ARN ?? "",
|
|
39
|
+
region: rawEnv.AWS_REGION ?? "eu-central-1",
|
|
40
|
+
rerankingEnabled,
|
|
41
|
+
},
|
|
42
|
+
github: {
|
|
43
|
+
requiredOrganizations: parseRequiredOrganizations(rawEnv.REQUIRED_ORGANIZATIONS),
|
|
44
|
+
searchOrg: rawEnv.GITHUB_SEARCH_ORG ?? "pagopa",
|
|
45
|
+
},
|
|
46
|
+
logLevel: rawEnv.LOG_LEVEL,
|
|
47
|
+
monitoring: {
|
|
48
|
+
connectionString: rawEnv.APPLICATIONINSIGHTS_CONNECTION_STRING,
|
|
49
|
+
samplingRatio: (rawEnv.APPINSIGHTS_SAMPLING_PERCENTAGE ?? 5) / 100,
|
|
50
|
+
},
|
|
51
|
+
port,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Parses a comma-separated list of organization names from a string.
|
|
56
|
+
* Defaults to ["pagopa"] if the list is empty or undefined.
|
|
57
|
+
*
|
|
58
|
+
* @param value - Comma-separated organization names
|
|
59
|
+
* @returns Array of organization names
|
|
60
|
+
*/
|
|
61
|
+
export function parseRequiredOrganizations(value) {
|
|
62
|
+
const organizations = (value ?? "")
|
|
63
|
+
.split(",")
|
|
64
|
+
.map((org) => org.trim())
|
|
65
|
+
.filter(Boolean);
|
|
66
|
+
return organizations.length > 0 ? organizations : ["pagopa"];
|
|
67
|
+
}
|
|
@@ -1,29 +1,26 @@
|
|
|
1
1
|
import { getLogger } from "@logtape/logtape";
|
|
2
2
|
import { emitCustomEvent } from "@pagopa/azure-tracing/logger";
|
|
3
|
+
import { filterUndefined } from "../utils/filter-undefined.js";
|
|
3
4
|
const logger = getLogger(["mcpserver", "prompt-logging"]);
|
|
4
5
|
/**
|
|
5
6
|
* Decorator that adds logging to prompt load functions.
|
|
6
7
|
* Logs when a prompt is requested to both console and Azure Application Insights.
|
|
7
8
|
*/
|
|
8
|
-
export function withPromptLogging(prompt, catalogId) {
|
|
9
|
+
export function withPromptLogging(prompt, catalogId, requestId) {
|
|
9
10
|
const originalLoad = prompt.load;
|
|
10
11
|
return {
|
|
11
12
|
...prompt,
|
|
12
13
|
load: async (args) => {
|
|
13
|
-
const eventData = {
|
|
14
|
-
arguments: JSON.stringify(args),
|
|
15
|
-
promptId: catalogId,
|
|
16
|
-
promptName: prompt.name,
|
|
17
|
-
timestamp: new Date().toISOString(),
|
|
18
|
-
};
|
|
19
14
|
// Log to console (goes to CloudWatch in Lambda)
|
|
20
|
-
logger.debug(`Prompt requested: ${prompt.name}
|
|
15
|
+
logger.debug(`Prompt requested: ${prompt.name} (ID: ${catalogId})`);
|
|
21
16
|
// Emit custom event to Azure Application Insights
|
|
22
|
-
|
|
23
|
-
|
|
17
|
+
// Note: Arguments are not logged to avoid exposing sensitive data
|
|
18
|
+
emitCustomEvent("PromptRequested", filterUndefined({
|
|
24
19
|
promptId: catalogId,
|
|
25
20
|
promptName: prompt.name,
|
|
26
|
-
|
|
21
|
+
requestId,
|
|
22
|
+
timestamp: new Date().toISOString(),
|
|
23
|
+
}))("mcpserver");
|
|
27
24
|
// Call the original load function and return the result
|
|
28
25
|
return await originalLoad(args);
|
|
29
26
|
},
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { getLogger } from "@logtape/logtape";
|
|
2
2
|
import { emitCustomEvent } from "@pagopa/azure-tracing/logger";
|
|
3
|
+
import { filterUndefined } from "../utils/filter-undefined.js";
|
|
3
4
|
const logger = getLogger(["mcpserver", "tool-logging"]);
|
|
4
5
|
/**
|
|
5
6
|
* Decorator that adds logging to tool execute functions.
|
|
@@ -7,36 +8,34 @@ const logger = getLogger(["mcpserver", "tool-logging"]);
|
|
|
7
8
|
* Preserves the exact original function signature and type.
|
|
8
9
|
*/
|
|
9
10
|
export function withToolLogging(tool) {
|
|
10
|
-
if (typeof tool !== "object" || !tool.execute || !tool.name) {
|
|
11
|
-
return tool;
|
|
12
|
-
}
|
|
13
11
|
const originalExecute = tool.execute;
|
|
14
12
|
const toolName = tool.name;
|
|
15
13
|
return {
|
|
16
14
|
...tool,
|
|
17
|
-
execute: async (
|
|
15
|
+
execute: async (args, context) => {
|
|
16
|
+
// Cast args to the expected type for tool execution
|
|
18
17
|
const startTime = Date.now();
|
|
19
|
-
const [toolArgs] = args;
|
|
20
|
-
const eventData = {
|
|
21
|
-
arguments: JSON.stringify(toolArgs),
|
|
22
|
-
timestamp: new Date().toISOString(),
|
|
23
|
-
toolName,
|
|
24
|
-
};
|
|
25
18
|
// Log to console (goes to CloudWatch in Lambda)
|
|
26
|
-
logger.debug(`Tool executed: ${toolName}
|
|
19
|
+
logger.debug(`Tool executed: ${toolName}`);
|
|
27
20
|
// Emit custom event to Azure Application Insights
|
|
21
|
+
const eventData = filterUndefined({
|
|
22
|
+
requestId: context?.requestId,
|
|
23
|
+
timestamp: new Date().toISOString(),
|
|
24
|
+
toolName,
|
|
25
|
+
});
|
|
28
26
|
emitCustomEvent("ToolExecuted", eventData)("mcpserver");
|
|
29
27
|
try {
|
|
30
28
|
// Call the original execute function and return the result
|
|
31
|
-
const result = await originalExecute(
|
|
29
|
+
const result = await originalExecute(args, context);
|
|
32
30
|
const executionTime = Date.now() - startTime;
|
|
33
31
|
// Log successful completion
|
|
34
32
|
logger.debug(`Tool completed: ${toolName} - execution time: ${executionTime}ms`);
|
|
35
33
|
// Emit completion event to Azure Application Insights
|
|
36
|
-
emitCustomEvent("ToolCompleted", {
|
|
34
|
+
emitCustomEvent("ToolCompleted", filterUndefined({
|
|
37
35
|
executionTimeMs: executionTime.toString(),
|
|
36
|
+
requestId: context?.requestId,
|
|
38
37
|
toolName,
|
|
39
|
-
})("mcpserver");
|
|
38
|
+
}))("mcpserver");
|
|
40
39
|
return result;
|
|
41
40
|
}
|
|
42
41
|
catch (error) {
|
|
@@ -44,11 +43,12 @@ export function withToolLogging(tool) {
|
|
|
44
43
|
// Log error
|
|
45
44
|
logger.error(`Tool failed: ${toolName} - ${error instanceof Error ? error.message : String(error)} - execution time: ${executionTime}ms`);
|
|
46
45
|
// Emit error event to Azure Application Insights
|
|
47
|
-
emitCustomEvent("ToolFailed", {
|
|
46
|
+
emitCustomEvent("ToolFailed", filterUndefined({
|
|
48
47
|
error: error instanceof Error ? error.message : String(error),
|
|
49
48
|
executionTimeMs: executionTime.toString(),
|
|
49
|
+
requestId: context?.requestId,
|
|
50
50
|
toolName,
|
|
51
|
-
})("mcpserver");
|
|
51
|
+
}))("mcpserver");
|
|
52
52
|
throw error;
|
|
53
53
|
}
|
|
54
54
|
},
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { getLogger } from "@logtape/logtape";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { retrieveAndGenerate } from "../services/bedrock-retrieve-and-generate.js";
|
|
4
|
+
import { resolveToWebsiteUrl } from "../services/bedrock.js";
|
|
5
|
+
import { parseJsonBody, sendErrorResponse, sendJsonResponse, } from "../utils/http.js";
|
|
6
|
+
const AskBodySchema = z.object({
|
|
7
|
+
query: z
|
|
8
|
+
.string({
|
|
9
|
+
invalid_type_error: "Missing required field: query",
|
|
10
|
+
required_error: "Missing required field: query",
|
|
11
|
+
})
|
|
12
|
+
.trim()
|
|
13
|
+
.min(1, "Missing required field: query"),
|
|
14
|
+
});
|
|
15
|
+
export async function handleAskEndpoint(req, res, config, kbRuntimeClient) {
|
|
16
|
+
const logger = getLogger(["mcpserver", "handler", "ask"]);
|
|
17
|
+
logger.debug("Handling /ask endpoint");
|
|
18
|
+
try {
|
|
19
|
+
let jsonBody;
|
|
20
|
+
try {
|
|
21
|
+
jsonBody = await parseJsonBody(req);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return sendErrorResponse(res, 400, "Invalid JSON in request body");
|
|
25
|
+
}
|
|
26
|
+
const result = AskBodySchema.safeParse(jsonBody);
|
|
27
|
+
if (!result.success) {
|
|
28
|
+
return sendErrorResponse(res, 400, result.error.errors[0].message);
|
|
29
|
+
}
|
|
30
|
+
const { query } = result.data;
|
|
31
|
+
const response = await retrieveAndGenerate(config.aws.knowledgeBaseId, config.aws.modelArn, query, kbRuntimeClient);
|
|
32
|
+
// Extract unique source URLs from citations
|
|
33
|
+
const sourceUrls = new Set();
|
|
34
|
+
response.citations?.forEach((citation) => {
|
|
35
|
+
citation.retrievedReferences?.forEach((ref) => {
|
|
36
|
+
const webLocation = resolveToWebsiteUrl(ref.location);
|
|
37
|
+
if (webLocation?.webLocation?.url) {
|
|
38
|
+
sourceUrls.add(webLocation.webLocation.url);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
sendJsonResponse(res, 200, {
|
|
43
|
+
answer: response.output?.text || "",
|
|
44
|
+
sources: Array.from(sourceUrls),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
49
|
+
logger.error(`Error handling /ask request: ${errorMessage}`);
|
|
50
|
+
sendErrorResponse(res, 500, "Internal server error", {
|
|
51
|
+
message: errorMessage,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { getLogger } from "@logtape/logtape";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { queryKnowledgeBaseStructured } from "../services/bedrock.js";
|
|
4
|
+
import { parseJsonBody, sendErrorResponse, sendJsonResponse, } from "../utils/http.js";
|
|
5
|
+
const SearchBodySchema = z.object({
|
|
6
|
+
number_of_results: z
|
|
7
|
+
.number({
|
|
8
|
+
invalid_type_error: "number_of_results must be between 1 and 20",
|
|
9
|
+
})
|
|
10
|
+
.int()
|
|
11
|
+
.min(1, "number_of_results must be between 1 and 20")
|
|
12
|
+
.max(20, "number_of_results must be between 1 and 20")
|
|
13
|
+
.optional()
|
|
14
|
+
.default(5),
|
|
15
|
+
query: z
|
|
16
|
+
.string({
|
|
17
|
+
invalid_type_error: "Missing required field: query",
|
|
18
|
+
required_error: "Missing required field: query",
|
|
19
|
+
})
|
|
20
|
+
.trim()
|
|
21
|
+
.min(1, "Missing required field: query"),
|
|
22
|
+
});
|
|
23
|
+
export async function handleSearchEndpoint(req, res, config, kbRuntimeClient) {
|
|
24
|
+
const logger = getLogger(["mcpserver", "handler", "search"]);
|
|
25
|
+
logger.debug("Handling /search endpoint");
|
|
26
|
+
try {
|
|
27
|
+
let jsonBody;
|
|
28
|
+
try {
|
|
29
|
+
jsonBody = await parseJsonBody(req);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return sendErrorResponse(res, 400, "Invalid JSON in request body");
|
|
33
|
+
}
|
|
34
|
+
const result = SearchBodySchema.safeParse(jsonBody);
|
|
35
|
+
if (!result.success) {
|
|
36
|
+
return sendErrorResponse(res, 400, result.error.errors[0].message);
|
|
37
|
+
}
|
|
38
|
+
const { number_of_results: numberOfResults, query } = result.data;
|
|
39
|
+
const results = await queryKnowledgeBaseStructured(config.aws.knowledgeBaseId, query, kbRuntimeClient, numberOfResults, config.aws.rerankingEnabled);
|
|
40
|
+
// Format results with content, score, and source URL
|
|
41
|
+
const formattedResults = results.map((result) => ({
|
|
42
|
+
content: result.content,
|
|
43
|
+
score: result.score,
|
|
44
|
+
source: result.location?.type === "WEB"
|
|
45
|
+
? result.location.webLocation?.url
|
|
46
|
+
: undefined,
|
|
47
|
+
}));
|
|
48
|
+
sendJsonResponse(res, 200, {
|
|
49
|
+
query,
|
|
50
|
+
results: formattedResults,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
55
|
+
logger.error(`Error handling /search request: ${errorMessage}`);
|
|
56
|
+
sendErrorResponse(res, 500, "Internal server error", {
|
|
57
|
+
message: errorMessage,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -1,62 +1,20 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* PagoPA DX Knowledge Retrieval MCP Server
|
|
3
|
+
*
|
|
4
|
+
* This module exposes the main server startup logic without triggering side effects
|
|
5
|
+
* at import time. All runtime setup flows from the main entrypoint.
|
|
6
|
+
*/
|
|
2
7
|
import { getEnabledPrompts } from "@pagopa/dx-mcpprompts";
|
|
3
|
-
import {
|
|
4
|
-
import { verifyGithubUser } from "./auth/github.js";
|
|
8
|
+
import { loadConfig } from "./config.js";
|
|
5
9
|
import { configureLogging } from "./config/logging.js";
|
|
6
10
|
import { configureAzureMonitoring } from "./config/monitoring.js";
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
await
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
// Authentication is enabled based on the AUTH_REQUIRED environment variable.
|
|
18
|
-
const server = new FastMCP({
|
|
19
|
-
authenticate: async (request) => {
|
|
20
|
-
const authHeader = request.headers["x-gh-pat"];
|
|
21
|
-
const apiKey = typeof authHeader === "string"
|
|
22
|
-
? authHeader
|
|
23
|
-
: Array.isArray(authHeader)
|
|
24
|
-
? authHeader[0]
|
|
25
|
-
: undefined;
|
|
26
|
-
if (!apiKey || !(await verifyGithubUser(apiKey))) {
|
|
27
|
-
throw new Response(null, {
|
|
28
|
-
status: 401,
|
|
29
|
-
statusText: "Unauthorized",
|
|
30
|
-
});
|
|
31
|
-
}
|
|
32
|
-
// The returned object is accessible in the `context.session`.
|
|
33
|
-
return {
|
|
34
|
-
id: 1,
|
|
35
|
-
token: apiKey,
|
|
36
|
-
};
|
|
37
|
-
},
|
|
38
|
-
instructions: serverInstructions,
|
|
39
|
-
name: "PagoPA DX Knowledge Retrieval MCP Server",
|
|
40
|
-
version: "0.0.0",
|
|
41
|
-
});
|
|
42
|
-
logger.debug(`Server instructions: \n\n${serverInstructions}`);
|
|
43
|
-
logger.debug(`Loading enabled prompts...`);
|
|
44
|
-
getEnabledPrompts().then((prompts) => {
|
|
45
|
-
prompts.forEach((catalogEntry) => {
|
|
46
|
-
logger.debug(`Adding prompt: ${catalogEntry.prompt.name}`);
|
|
47
|
-
// Apply logging decorator to the prompt
|
|
48
|
-
const decoratedPrompt = withPromptLogging(catalogEntry.prompt, catalogEntry.id);
|
|
49
|
-
server.addPrompt(decoratedPrompt);
|
|
50
|
-
logger.debug(`Added prompt: ${catalogEntry.prompt.name}`);
|
|
51
|
-
});
|
|
52
|
-
});
|
|
53
|
-
server.addTool(withToolLogging(QueryPagoPADXDocumentationTool));
|
|
54
|
-
server.addTool(withToolLogging(SearchGitHubCodeTool));
|
|
55
|
-
// Starts the server in HTTP Stream mode.
|
|
56
|
-
server.start({
|
|
57
|
-
httpStream: {
|
|
58
|
-
port: 8080,
|
|
59
|
-
stateless: true,
|
|
60
|
-
},
|
|
61
|
-
transportType: "httpStream",
|
|
62
|
-
});
|
|
11
|
+
import { startHttpServer } from "./server.js";
|
|
12
|
+
export async function main(env) {
|
|
13
|
+
const config = loadConfig(env);
|
|
14
|
+
await configureLogging(config.logLevel);
|
|
15
|
+
configureAzureMonitoring(config.monitoring);
|
|
16
|
+
const enabledPrompts = await getEnabledPrompts();
|
|
17
|
+
const httpServer = await startHttpServer(config, enabledPrompts);
|
|
18
|
+
return httpServer;
|
|
19
|
+
}
|
|
20
|
+
export { startHttpServer };
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { getLogger } from "@logtape/logtape";
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import packageJson from "../../package.json" with { type: "json" };
|
|
5
|
+
import { withPromptLogging } from "../decorators/prompt-usage-monitoring.js";
|
|
6
|
+
import { withToolLogging } from "../decorators/tool-usage-monitoring.js";
|
|
7
|
+
import { sessionStorage } from "../session.js";
|
|
8
|
+
export function createServer({ enabledPrompts, requestId, toolDefinitions, }) {
|
|
9
|
+
const logger = getLogger(["mcpserver"]);
|
|
10
|
+
const mcpServer = new McpServer({
|
|
11
|
+
name: "pagopa-dx-mcp-server",
|
|
12
|
+
version: packageJson.version,
|
|
13
|
+
});
|
|
14
|
+
/**
|
|
15
|
+
* Register tools dynamically from the tool registry.
|
|
16
|
+
* This pattern allows tools to be added/removed by simply updating the registry,
|
|
17
|
+
* without needing to modify this registration code.
|
|
18
|
+
*/
|
|
19
|
+
toolDefinitions.forEach(({ id, requiresSession, tool: toolDef }) => {
|
|
20
|
+
const decoratedTool = withToolLogging(toolDef);
|
|
21
|
+
const { annotations } = decoratedTool;
|
|
22
|
+
// Ensure parameters is a ZodObject (all tools must use z.object())
|
|
23
|
+
if (!(decoratedTool.parameters instanceof z.ZodObject)) {
|
|
24
|
+
throw new Error(`Tool "${id}" must use z.object() for parameters schema`);
|
|
25
|
+
}
|
|
26
|
+
const zodObject = decoratedTool.parameters;
|
|
27
|
+
mcpServer.registerTool(id, {
|
|
28
|
+
annotations: {
|
|
29
|
+
destructiveHint: annotations.destructiveHint ?? false,
|
|
30
|
+
idempotentHint: annotations.idempotentHint ?? true,
|
|
31
|
+
openWorldHint: annotations.openWorldHint ?? true,
|
|
32
|
+
readOnlyHint: annotations.readOnlyHint ?? true,
|
|
33
|
+
},
|
|
34
|
+
description: decoratedTool.description,
|
|
35
|
+
inputSchema: zodObject.shape,
|
|
36
|
+
title: annotations.title,
|
|
37
|
+
}, async (args) => {
|
|
38
|
+
const store = sessionStorage.getStore();
|
|
39
|
+
const context = requiresSession
|
|
40
|
+
? {
|
|
41
|
+
requestId: store?.requestId,
|
|
42
|
+
session: store,
|
|
43
|
+
}
|
|
44
|
+
: undefined;
|
|
45
|
+
const result = await decoratedTool.execute(args, context);
|
|
46
|
+
return {
|
|
47
|
+
content: [
|
|
48
|
+
{
|
|
49
|
+
text: typeof result === "string" ? result : JSON.stringify(result),
|
|
50
|
+
type: "text",
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
};
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
// Register prompts using the modern registerPrompt pattern
|
|
57
|
+
enabledPrompts.forEach((catalogEntry) => {
|
|
58
|
+
const decoratedPrompt = withPromptLogging(catalogEntry.prompt, catalogEntry.id, requestId);
|
|
59
|
+
// Build Zod schema from prompt arguments
|
|
60
|
+
const argsSchemaShape = {};
|
|
61
|
+
for (const arg of catalogEntry.prompt.arguments) {
|
|
62
|
+
const fieldSchema = z.string().describe(arg.description);
|
|
63
|
+
argsSchemaShape[arg.name] = arg.required
|
|
64
|
+
? fieldSchema
|
|
65
|
+
: fieldSchema.optional();
|
|
66
|
+
}
|
|
67
|
+
const zodObject = z.object(argsSchemaShape);
|
|
68
|
+
mcpServer.registerPrompt(catalogEntry.prompt.name, {
|
|
69
|
+
argsSchema: zodObject.shape,
|
|
70
|
+
description: catalogEntry.prompt.description,
|
|
71
|
+
}, async (args) => {
|
|
72
|
+
const content = await decoratedPrompt.load(args || {});
|
|
73
|
+
return {
|
|
74
|
+
messages: [
|
|
75
|
+
{
|
|
76
|
+
content: {
|
|
77
|
+
text: content,
|
|
78
|
+
type: "text",
|
|
79
|
+
},
|
|
80
|
+
role: "user",
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
};
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
logger.debug(`Server initialized with ${toolDefinitions.length} tools and ${enabledPrompts.length} prompts`);
|
|
87
|
+
return mcpServer;
|
|
88
|
+
}
|