Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 93 additions & 6 deletions actions/setup/js/mcp_server_core.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ const { getErrorMessage } = require("./error_helpers.cjs");
const { generateEnhancedErrorMessage } = require("./mcp_enhanced_errors.cjs");

const encoder = new TextEncoder();
const PARAMETER_SIMILARITY_DISTANCE_BONUS = 2;
const UNKNOWN_PARAMETER_LIST_PREVIEW_MAX = 10;

/**
* @typedef {Object} ServerInfo
Expand Down Expand Up @@ -66,7 +68,7 @@ const encoder = new TextEncoder();
* @property {string} [logDir] - Optional log directory
* @property {string} [logFilePath] - Optional log file path
* @property {boolean} logFileInitialized - Whether log file has been initialized
* @property {(toolName: string, args: any) => any} [normalizeArguments] - Optional tool argument normalizer
* @property {(toolName: string, args: any, tool?: Tool) => any} [normalizeArguments] - Optional tool argument normalizer
*/

/**
Expand Down Expand Up @@ -188,7 +190,7 @@ function createReplyErrorFunction(server) {
* @param {ServerInfo} serverInfo - Server information (name and version)
* @param {Object} [options] - Optional server configuration
* @param {string} [options.logDir] - Directory for log file (optional)
* @param {(toolName: string, args: any) => any} [options.normalizeArguments] - Optional tool argument normalizer
* @param {(toolName: string, args: any, tool?: Tool) => any} [options.normalizeArguments] - Optional tool argument normalizer
* @returns {MCPServer} The MCP server instance
*/
function createServer(serverInfo, options = {}) {
Expand Down Expand Up @@ -533,6 +535,77 @@ function findSimilarTools(requestedTool, availableTools, maxSuggestions = 3) {
return suggestions.filter(s => s.distance <= maxDistance).slice(0, maxSuggestions);
}

/**
* Find similar parameter names from available input schema properties.
* @param {string} requestedParameter - The parameter name that was provided
* @param {string[]} availableParameters - Available parameter names
* @param {number} maxSuggestions - Maximum number of suggestions to return
* @returns {Array<{name: string, distance: number}>} Similar parameter names
*/
function findSimilarParameters(requestedParameter, availableParameters, maxSuggestions = 3) {
const normalizedRequested = normalizeTool(requestedParameter);
const suggestions = availableParameters.map(parameterName => ({
name: parameterName,
distance: levenshteinDistance(normalizedRequested, normalizeTool(parameterName)),
}));

suggestions.sort((a, b) => a.distance - b.distance);
// Parameter names are typically short; use a slightly tighter threshold than tool names
// so only clearly related misspellings are suggested.
const maxDistance = Math.floor(normalizedRequested.length / 2) + PARAMETER_SIMILARITY_DISTANCE_BONUS;
return suggestions.filter(s => s.distance <= maxDistance).slice(0, maxSuggestions);
}

/**
* Validate unknown arguments against strict input schemas.
* @param {any} args
* @param {any} inputSchema
* @returns {string[]} Unknown parameter names
*/
function validateUnknownParameters(args, inputSchema) {
if (!args || typeof args !== "object" || Array.isArray(args)) {
return [];
}
if (!inputSchema || typeof inputSchema !== "object") {
return [];
}
if (inputSchema.additionalProperties !== false) {
return [];
}
if (!inputSchema.properties || typeof inputSchema.properties !== "object" || Array.isArray(inputSchema.properties)) {
return [];
}

const allowed = new Set(Object.keys(inputSchema.properties));
return Object.keys(args).filter(key => !allowed.has(key));
}

/**
* Build a strict unknown-parameter validation error.
* @param {string[]} unknownParameters
* @param {any} inputSchema
* @returns {string}
*/
function buildUnknownParameterError(unknownParameters, inputSchema) {
const allowedParameters = inputSchema && inputSchema.properties && typeof inputSchema.properties === "object" ? Object.keys(inputSchema.properties) : [];

const unknownDetails = unknownParameters.map(parameterName => {
const similar = findSimilarParameters(parameterName, allowedParameters);
if (!similar.length) {
return `'${parameterName}'`;
}
return `'${parameterName}' (closest: ${similar.map(s => `'${s.name}'`).join(", ")})`;
});

const preview = allowedParameters
.slice(0, UNKNOWN_PARAMETER_LIST_PREVIEW_MAX)
.map(name => `'${name}'`)
.join(", ");
const remainingCount = Math.max(allowedParameters.length - UNKNOWN_PARAMETER_LIST_PREVIEW_MAX, 0);
const suffix = allowedParameters.length > 0 ? ` Supported parameters for this tool: ${preview}${remainingCount > 0 ? `, ... (+${remainingCount} more)` : ""}.` : "";
return `Invalid arguments: unknown parameter${unknownParameters.length === 1 ? "" : "s"} ${unknownDetails.join(", ")}.${suffix}`;
}

/**
* Normalize a tool name (convert dashes to underscores, lowercase)
* @param {string} name - The tool name to normalize
Expand Down Expand Up @@ -567,11 +640,11 @@ function containsAtFilepathReference(value) {
* @param {any} args
* @returns {any}
*/
function normalizeToolArguments(server, toolName, args) {
function normalizeToolArguments(server, toolName, args, tool) {
if (typeof server.normalizeArguments !== "function") {
return args;
}
const normalized = server.normalizeArguments(toolName, args);
const normalized = server.normalizeArguments(toolName, args, tool);
return normalized == null ? args : normalized;
}

Expand Down Expand Up @@ -657,14 +730,22 @@ async function handleRequest(server, request, defaultHandler) {
};
}

const args = normalizeToolArguments(server, tool.name, rawArgs);
const args = normalizeToolArguments(server, tool.name, rawArgs, tool);
if (containsAtFilepathReference(args)) {
throw {
code: -32602,
message: "Invalid params: local file references using @filepath notation are not supported by this MCP server. Do not attempt to inline files. Provide the needed content directly in arguments instead.",
};
}

const unknownParameters = validateUnknownParameters(args, tool.inputSchema);
if (unknownParameters.length) {
throw {
code: -32602,
message: buildUnknownParameterError(unknownParameters, tool.inputSchema),
};
}

const missing = validateRequiredFields(args, tool.inputSchema);
if (missing.length) {
const hasRequiredFields = tool.inputSchema && Array.isArray(tool.inputSchema.required) && tool.inputSchema.required.length > 0;
Expand Down Expand Up @@ -810,12 +891,18 @@ async function handleMessage(server, req, defaultHandler) {
return;
}

const args = normalizeToolArguments(server, tool.name, rawArgs);
const args = normalizeToolArguments(server, tool.name, rawArgs, tool);
if (containsAtFilepathReference(args)) {
server.replyError(id, -32602, "Invalid params: local file references using @filepath notation are not supported by this MCP server. Do not attempt to inline files. Provide the needed content directly in arguments instead.");
return;
}

const unknownParameters = validateUnknownParameters(args, tool.inputSchema);
if (unknownParameters.length) {
server.replyError(id, -32602, buildUnknownParameterError(unknownParameters, tool.inputSchema));
return;
}

const missing = validateRequiredFields(args, tool.inputSchema);
if (missing.length) {
const hasRequiredFields = tool.inputSchema && Array.isArray(tool.inputSchema.required) && tool.inputSchema.required.length > 0;
Expand Down
22 changes: 22 additions & 0 deletions actions/setup/js/mcp_server_core.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ describe("mcp_server_core.cjs", () => {
type: "object",
properties: { input: { type: "string", description: "Input text to process" } },
required: ["input"],
additionalProperties: false,
},
handler: args => ({
content: [{ type: "text", text: `received: ${args.input}` }],
Expand Down Expand Up @@ -346,6 +347,27 @@ describe("mcp_server_core.cjs", () => {
expect(results[0].error.message).toContain("Example:");
});

it("should reject unknown parameters with closest valid suggestions", async () => {
const { handleMessage } = await import("./mcp_server_core.cjs");

await handleMessage(server, {
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: {
name: "test_tool",
arguments: { inpt: "hello" },
},
});

expect(results).toHaveLength(1);
expect(results[0].error.code).toBe(-32602);
expect(results[0].error.message).toContain("unknown parameter");
expect(results[0].error.message).toContain("'inpt'");
expect(results[0].error.message).toContain("'input'");
expect(results[0].error.message).toContain("Supported parameters for this tool");
});

it("should return error for unknown method", async () => {
const { handleMessage } = await import("./mcp_server_core.cjs");

Expand Down
75 changes: 70 additions & 5 deletions actions/setup/js/safe_outputs_mcp_arguments.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,31 +8,96 @@ const { normalizeTool } = require("./mcp_server_core.cjs");
* @param {string} toolName
* @param {any} args
* @param {{ debug?: (...args: any[]) => void }} [logger]
* @param {any} [inputSchema]
* @returns {any}
*/
function normalizeSafeOutputToolArguments(toolName, args, logger) {
function normalizeSafeOutputToolArguments(toolName, args, logger, inputSchema) {
if (!args || typeof args !== "object" || Array.isArray(args)) {
return args;
}
if (typeof args.type === "string" && args.type.trim()) {
return args;
}

let normalizedArgs = args;
const normalizedToolName = normalizeTool(toolName);
const candidateKeys = [...new Set([toolName, normalizedToolName, toolName.replace(/_/g, "-"), normalizedToolName.replace(/_/g, "-")])];

for (const candidateKey of candidateKeys) {
const nestedArgs = args[candidateKey];
const nestedArgs = normalizedArgs[candidateKey];
if (nestedArgs && typeof nestedArgs === "object" && !Array.isArray(nestedArgs)) {
const outerKeys = Object.keys(args);
const outerKeys = Object.keys(normalizedArgs);
logger?.debug?.(`Recovered wrapped safe-output tool arguments for '${normalizedToolName}' by unwrapping key '${candidateKey}' from payload keys ${JSON.stringify(outerKeys)}`);
return nestedArgs;
normalizedArgs = nestedArgs;
break;
}
}

return args;
if (!inputSchema || !inputSchema.properties || typeof inputSchema.properties !== "object" || Array.isArray(inputSchema.properties)) {
return normalizedArgs;
}

const parameterSynonyms = new Map();
for (const [parameterName, parameterSchema] of Object.entries(inputSchema.properties)) {
parameterSynonyms.set(normalizeTool(parameterName), parameterName);
const synonyms = Array.isArray(parameterSchema?.["x-synonyms"]) ? parameterSchema["x-synonyms"] : [];
for (const synonym of synonyms) {
if (typeof synonym !== "string" || !synonym.trim()) {
continue;
}
const normalizedSynonym = normalizeTool(synonym.trim());
if (!parameterSynonyms.has(normalizedSynonym)) {
parameterSynonyms.set(normalizedSynonym, parameterName);
}
}
}

const remapped = [];
const remappedArgs = { ...normalizedArgs };
for (const [providedName, value] of Object.entries(normalizedArgs)) {
const mappedName = parameterSynonyms.get(normalizeTool(providedName));
if (!mappedName || mappedName === providedName) {
continue;
}

if (remappedArgs[mappedName] === undefined) {
remappedArgs[mappedName] = value;
}
delete remappedArgs[providedName];
remapped.push({ from: providedName, to: mappedName });
}

if (remapped.length > 0) {
logger?.debug?.(`Recovered safe-output parameter synonyms for '${normalizedToolName}': ${JSON.stringify(remapped)}`);
}

return remappedArgs;
}

/**
* Remove internal safe-output schema metadata before exposing schemas to LLMs.
* @param {any} schema
* @returns {any}
*/
function stripInternalSafeOutputSchemaMetadata(schema) {
if (!schema || typeof schema !== "object") {
return schema;
}
if (Array.isArray(schema)) {
return schema.map(stripInternalSafeOutputSchemaMetadata);
}

const cleaned = {};
for (const [key, value] of Object.entries(schema)) {
if (key === "x-synonyms") {
continue;
}
cleaned[key] = stripInternalSafeOutputSchemaMetadata(value);
}
return cleaned;
}

module.exports = {
normalizeSafeOutputToolArguments,
stripInternalSafeOutputSchemaMetadata,
};
23 changes: 18 additions & 5 deletions actions/setup/js/safe_outputs_mcp_server.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const { attachHandlers, registerPredefinedTools, registerDynamicTools } = requir
const { bootstrapSafeOutputsServer, cleanupConfigFile } = require("./safe_outputs_bootstrap.cjs");
const { getErrorMessage } = require("./error_helpers.cjs");
const { ERR_VALIDATION } = require("./error_codes.cjs");
const { normalizeSafeOutputToolArguments } = require("./safe_outputs_mcp_arguments.cjs");
const { normalizeSafeOutputToolArguments, stripInternalSafeOutputSchemaMetadata } = require("./safe_outputs_mcp_arguments.cjs");

/**
* Start the safe-outputs MCP server
Expand All @@ -31,14 +31,21 @@ function startSafeOutputsServer(options = {}) {
// Server info for safe outputs MCP server
const SERVER_INFO = { name: "safeoutputs", version: "1.0.0" };

const normalizationSchemas = new Map();

// Create the server instance with optional log directory
const MCP_LOG_DIR = options.logDir || process.env.GH_AW_MCP_LOG_DIR;
const server = createServer(SERVER_INFO, {
logDir: MCP_LOG_DIR,
normalizeArguments: (toolName, args) =>
normalizeSafeOutputToolArguments(toolName, args, {
debug: message => server.debug(message),
}),
normalizeArguments: (toolName, args, tool) =>
normalizeSafeOutputToolArguments(
toolName,
args,
{
debug: message => server.debug(message),
},
normalizationSchemas.get(normalizeTool(toolName)) || tool?.inputSchema
),
});

// Bootstrap: load configuration and tools using shared logic
Expand All @@ -53,6 +60,12 @@ function startSafeOutputsServer(options = {}) {

// Attach handlers to tools
const toolsWithHandlers = attachHandlers(ALL_TOOLS, handlers, server);
for (const tool of toolsWithHandlers) {
if (tool?.name && tool?.inputSchema) {
normalizationSchemas.set(normalizeTool(tool.name), tool.inputSchema);
tool.inputSchema = stripInternalSafeOutputSchemaMetadata(tool.inputSchema);
}
}

server.debug(` output file: ${outputFile}`);
server.debug(` config: ${JSON.stringify(safeOutputsConfig)}`);
Expand Down
12 changes: 10 additions & 2 deletions actions/setup/js/safe_outputs_mcp_server_http.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,12 @@ const { createAppendFunction } = require("./safe_outputs_append.cjs");
moduleLogger.debug("Loaded safe_outputs_append.cjs");
const { createHandlers } = require("./safe_outputs_handlers.cjs");
moduleLogger.debug("Loaded safe_outputs_handlers.cjs");
const { normalizeTool } = require("./mcp_server_core.cjs");
const { attachHandlers, registerPredefinedTools, registerDynamicTools } = require("./safe_outputs_tools_loader.cjs");
moduleLogger.debug("Loaded safe_outputs_tools_loader.cjs");
const { getErrorMessage } = require("./error_helpers.cjs");
const { runHttpServer, logStartupError } = require("./mcp_http_server_runner.cjs");
const { normalizeSafeOutputToolArguments } = require("./safe_outputs_mcp_arguments.cjs");
const { normalizeSafeOutputToolArguments, stripInternalSafeOutputSchemaMetadata } = require("./safe_outputs_mcp_arguments.cjs");
moduleLogger.debug("All modules loaded successfully");

/**
Expand All @@ -66,6 +67,7 @@ function createMCPServer(options = {}) {
// Create server with configuration
const serverName = "safeoutputs";
const version = "1.0.0";
const normalizationSchemas = new Map();

logger.debug(`Server name: ${serverName}`);
logger.debug(`Server version: ${version}`);
Expand All @@ -82,7 +84,7 @@ function createMCPServer(options = {}) {
capabilities: {
tools: {},
},
normalizeArguments: (toolName, args) => normalizeSafeOutputToolArguments(toolName, args, logger),
normalizeArguments: (toolName, args, tool) => normalizeSafeOutputToolArguments(toolName, args, logger, normalizationSchemas.get(normalizeTool(toolName)) || tool?.inputSchema),
}
);

Expand All @@ -95,6 +97,12 @@ function createMCPServer(options = {}) {

// Attach handlers to tools
const toolsWithHandlers = attachHandlers(ALL_TOOLS, handlers, logger);
for (const tool of toolsWithHandlers) {
if (tool?.name && tool?.inputSchema) {
normalizationSchemas.set(normalizeTool(tool.name), tool.inputSchema);
tool.inputSchema = stripInternalSafeOutputSchemaMetadata(tool.inputSchema);
}
}

// Register predefined tools that are enabled in configuration
logger.debug(`Registering predefined tools...`);
Expand Down
Loading