Merge branch 'main' into fix/enum-anyof-ref-resolution

This commit is contained in:
Ola Hungerford 2026-02-04 05:41:56 -07:00 • committed by GitHub
commit 442638f4c1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 16245 additions and 8744 deletions

View file

@ -43,8 +43,38 @@ describe("generateDefaultValue", () => {
expect(generateDefaultValue({ type: "array" })).toBe(undefined);
});
test("generates undefined for optional object", () => {
expect(generateDefaultValue({ type: "object" })).toBe(undefined);
test("generates empty object for optional root object", () => {
expect(generateDefaultValue({ type: "object" })).toEqual({});
});
test("generates undefined for nested optional object", () => {
// When called WITH propertyName and parentSchema, and the property is NOT required,
// nested optional objects should return undefined
const parentSchema = {
type: "object" as const,
required: ["otherField"],
properties: {
optionalObject: { type: "object" as const },
otherField: { type: "string" as const },
},
};
expect(
generateDefaultValue({ type: "object" }, "optionalObject", parentSchema),
).toBe(undefined);
});
test("generates empty object for root-level object with all optional properties", () => {
// Root-level schema with properties but no required array
// This is the exact scenario from PR #926 - elicitation with all optional fields
const schema: JsonSchemaType = {
type: "object",
properties: {
optionalField1: { type: "string" },
optionalField2: { type: "number" },
},
// No required array - all fields are optional
};
expect(generateDefaultValue(schema)).toEqual({});
});
test("generates default null for unknown types", () => {

View file

@ -54,6 +54,9 @@ export const getMCPProxyAuthToken = (
header: "X-MCP-Proxy-Auth",
};
};
export const getMCPTaskTtl = (config: InspectorConfig): number => {
return config.MCP_TASK_TTL.value as number;
};
export const getInitialTransportType = ():
| "stdio"

View file

@ -37,6 +37,9 @@ export type JsonSchemaType = {
default?: JsonValue;
properties?: Record<string, JsonSchemaType>;
items?: JsonSchemaType;
// Array validation constraints
minItems?: number;
maxItems?: number;
minimum?: number;
maximum?: number;
minLength?: number;
@ -45,6 +48,8 @@ export type JsonSchemaType = {
pattern?: string;
format?: string;
enum?: string[];
// Non-standard legacy support: titles for enum values
enumNames?: string[];
const?: JsonValue;
oneOf?: (JsonSchemaType | JsonSchemaConst)[];
anyOf?: (JsonSchemaType | JsonSchemaConst)[];

View file

@ -0,0 +1,162 @@
/**
* Metadata helpers aligned with the official MCP specification.
*
* @see https://modelcontextprotocol.io/specification/2025-06-18/basic/index#meta
*/
const META_PREFIX_LABEL_REGEX = /^[a-z](?:[a-z\d-]*[a-z\d])?$/i;
const META_NAME_REGEX = /^[a-z\d](?:[a-z\d._-]*[a-z\d])?$/i;
const RESERVED_NAMESPACE_LABELS = ["modelcontextprotocol", "mcp"];
export const RESERVED_NAMESPACE_MESSAGE =
'Keys using the "modelcontextprotocol.*" or "mcp.*" namespaces are reserved by MCP and cannot be used.';
export const META_NAME_RULES_MESSAGE =
"Names must begin and end with an alphanumeric character and may only contain alphanumerics, hyphens (-), underscores (_), or dots (.) in between.";
export const META_PREFIX_RULES_MESSAGE =
"Prefixes must be dot-separated labels that start with a letter and end with a letter or digit (e.g. example.domain/).";
/**
* Extracts the prefix portion (before the first slash) of a metadata key, if present.
*
* @param key - Raw metadata key entered by the user.
* @returns The prefix segment (without the trailing slash) or null when no prefix exists.
* @see https://modelcontextprotocol.io/specification/2025-06-18/basic/index#meta
*/
const getPrefixSegment = (key: string): string | null => {
const trimmedKey = key.trim();
const slashIndex = trimmedKey.indexOf("/");
if (slashIndex === -1) {
return null;
}
return trimmedKey.slice(0, slashIndex);
};
/**
* Normalizes a potential prefix segment by trimming whitespace, removing schemes,
* and stripping trailing URL components so only the label portion remains.
*
* @param segment - The prefix segment extracted from the metadata key.
* @returns A normalized string suitable for label parsing, or null when empty.
* @see https://modelcontextprotocol.io/specification/2025-06-18/basic/index#meta
*/
const normalizeSegment = (segment: string): string | null => {
if (!segment) return null;
let normalized = segment.trim().toLowerCase();
if (!normalized) return null;
const schemeIndex = normalized.indexOf("://");
if (schemeIndex !== -1) {
normalized = normalized.slice(schemeIndex + 3);
}
const stopChars = ["?", "#", ":"];
let endIndex = normalized.length;
stopChars.forEach((char) => {
const idx = normalized.indexOf(char);
if (idx !== -1 && idx < endIndex) {
endIndex = idx;
}
});
return normalized.slice(0, endIndex) || null;
};
/**
* Splits a normalized prefix into dot-separated labels and validates each label
* against the MCP prefix rules (start with letter, end with letter/digit, interior alphanumerics or hyphens).
*
* @param segment - Normalized prefix string.
* @returns Array of labels if valid, otherwise null.
* @see https://modelcontextprotocol.io/specification/2025-06-18/basic/index#meta
*/
const splitLabels = (segment: string): string[] | null => {
const normalized = normalizeSegment(segment);
if (!normalized) return null;
const labels = normalized.split(".");
if (
labels.length === 0 ||
labels.some((label) => !label || !META_PREFIX_LABEL_REGEX.test(label))
) {
return null;
}
return labels;
};
/**
* Determines whether a metadata key is within the MCP-reserved namespace.
*
* @param key - Full metadata key entered by the user.
* @returns True if the key's prefix belongs to a reserved namespace.
* @see https://modelcontextprotocol.io/specification/2025-06-18/basic/index#meta
*/
export const isReservedMetaKey = (key: string): boolean => {
const trimmedKey = key.trim();
if (!trimmedKey) {
return false;
}
const candidateSegment = getPrefixSegment(trimmedKey) ?? trimmedKey;
const labels = splitLabels(candidateSegment);
if (!labels || labels.length < 2) {
return false;
}
for (let i = 0; i < labels.length - 1; i += 1) {
const current = labels[i];
const next = labels[i + 1];
if (
RESERVED_NAMESPACE_LABELS.includes(current) &&
META_PREFIX_LABEL_REGEX.test(next)
) {
return true;
}
}
return false;
};
/**
* Validates the optional prefix portion of a metadata key.
*
* @param key - Full metadata key entered by the user.
* @returns True when the prefix is absent or satisfies the MCP label requirements.
* @see https://modelcontextprotocol.io/specification/2025-06-18/basic/index#meta
*/
export const hasValidMetaPrefix = (key: string): boolean => {
const prefixSegment = getPrefixSegment(key);
if (prefixSegment === null) {
return true;
}
return splitLabels(prefixSegment) !== null;
};
const extractMetaName = (key: string): string => {
const trimmedKey = key.trim();
if (!trimmedKey) return "";
const slashIndex = trimmedKey.lastIndexOf("/");
if (slashIndex === -1) {
return trimmedKey;
}
return trimmedKey.slice(slashIndex + 1);
};
/**
* Validates the "name" portion of a metadata key, regardless of whether a prefix exists.
*
* @param key - Full metadata key entered by the user.
* @returns True if the name portion is valid per the MCP spec.
* @see https://modelcontextprotocol.io/specification/2025-06-18/basic/index#meta
*/
export const hasValidMetaName = (key: string): boolean => {
const name = extractMetaName(key);
if (!name) return false;
return META_NAME_REGEX.test(name);
};

View file

@ -1,7 +1,8 @@
import type { JsonValue, JsonSchemaType, JsonObject } from "./jsonUtils";
import Ajv from "ajv";
import type { ValidateFunction } from "ajv";
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
import type { Tool, JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js";
import { isJSONRPCRequest } from "@modelcontextprotocol/sdk/types.js";
const ajv = new Ajv();
@ -99,6 +100,7 @@ export function generateDefaultValue(
propertyName && parentSchema
? isPropertyRequired(propertyName, parentSchema)
: false;
const isRootSchema = propertyName === undefined && parentSchema === undefined;
switch (schema.type) {
case "string":
@ -111,19 +113,27 @@ export function generateDefaultValue(
case "array":
return isRequired ? [] : undefined;
case "object": {
if (!schema.properties) return isRequired ? {} : undefined;
if (!schema.properties) {
return isRequired || isRootSchema ? {} : undefined;
}
const obj: JsonObject = {};
// Only include properties that are required according to the schema's required array
// Include required properties OR optional properties that declare a default
Object.entries(schema.properties).forEach(([key, prop]) => {
if (isPropertyRequired(key, schema)) {
const hasExplicitDefault =
"default" in prop && (prop as JsonSchemaType).default !== undefined;
if (isPropertyRequired(key, schema) || hasExplicitDefault) {
const value = generateDefaultValue(prop, key, schema);
if (value !== undefined) {
obj[key] = value;
}
}
});
return isRequired ? obj : Object.keys(obj).length > 0 ? obj : undefined;
if (Object.keys(obj).length === 0) {
return isRequired || isRootSchema ? {} : undefined;
}
return obj;
}
case "null":
return null;
@ -303,3 +313,41 @@ export function formatFieldLabel(key: string): string {
.replace(/_/g, " ") // Replace underscores with spaces
.replace(/^\w/, (c) => c.toUpperCase()); // Capitalize first letter
}
/**
* Resolves `$ref` references in a JSON-RPC "elicitation/create" message's `requestedSchema` field
* @param message The JSON-RPC message that may contain $ref references
* @returns A new message with resolved $ref references, or the original message if no resolution is needed
*/
export function resolveRefsInMessage(message: JSONRPCMessage): JSONRPCMessage {
if (!isJSONRPCRequest(message) || !message.params?.requestedSchema) {
return message;
}
const requestedSchema = message.params.requestedSchema as JsonSchemaType;
if (!requestedSchema?.properties) {
return message;
}
const resolvedMessage = {
...message,
params: {
...message.params,
requestedSchema: {
...requestedSchema,
properties: Object.fromEntries(
Object.entries(requestedSchema.properties).map(
([key, propSchema]) => {
const resolved = resolveRef(propSchema, requestedSchema);
const normalized = normalizeUnionType(resolved);
return [key, normalized];
},
),
),
},
},
};
return resolvedMessage;
}