Merge branch 'main' into fix/enum-anyof-ref-resolution
This commit is contained in:
commit
442638f4c1
5 changed files with 16245 additions and 8744 deletions
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)[];
|
||||
|
|
|
|||
162
client/src/utils/metaUtils.ts
Normal file
162
client/src/utils/metaUtils.ts
Normal 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);
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue