162 lines
5.1 KiB
TypeScript
162 lines
5.1 KiB
TypeScript
/**
|
|
* 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);
|
|
};
|