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
12 changes: 9 additions & 3 deletions src/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,8 @@
* compositions from the loaded parameter schema when Forge narrows them. */
function primitiveComposition(schema: Record<string, unknown> | undefined): Record<string, unknown> | undefined {
if (!schema) return undefined;
const keys = ['anyOf', 'oneOf'].filter(key => Array.isArray(schema[key]));
if (!Array.isArray(schema.anyOf) && !Array.isArray(schema.oneOf)) return undefined;
const keys = ['allOf', 'anyOf', 'oneOf'].filter(key => Array.isArray(schema[key]));
if (keys.length === 0) return undefined;
const compositions: Record<string, unknown> = {};
for (const key of keys) {
Expand All @@ -169,17 +170,22 @@
const mapped = branches.map((branch) => {
const node = resolveDocRef(branch) as Record<string, unknown> | undefined;
if (!node || typeof node.type !== 'string' || !['string', 'number', 'integer', 'boolean', 'null'].includes(node.type)) return undefined;
const { $ref: _ref, nullable: _nullable, xml: _xml, ...constraints } = node;
const { $ref: _ref, nullable: _nullable, xml: _xml, format: _format, ...constraints } = node;
if (key !== 'allOf' && _format !== undefined) constraints.format = _format;
return constraints;
});
if (mapped.some((branch) => branch === undefined)) return undefined;
compositions[key] = mapped;
}
return {
const result = {
...(typeof schema.type === 'string' ? { type: schema.type } : {}),
...(Array.isArray(schema.enum) ? { enum: schema.enum } : {}),
...compositions,
};
return {
...(schema.nullable === true ? { anyOf: [result, { type: 'null' }] } : result),
...(schema.default !== undefined ? { default: schema.default } : {}),
};
}

/**
Expand Down Expand Up @@ -610,43 +616,43 @@
const SCHEMA_ARRAY_KEYS = new Set(['allOf', 'anyOf', 'oneOf', 'prefixItems']);
const SCHEMA_MAP_KEYS = new Set(['properties', 'patternProperties', 'dependentSchemas', '$defs', 'definitions']);

function stripWriteOnlyRequired(node: unknown, inherited = new Set<string>()): void {
if (!node || typeof node !== 'object' || Array.isArray(node)) return;
const rec = node as Record<string, unknown>;
const writeOnly = new Set(inherited);
const collect = (schema: Record<string, unknown>): void => {
const props = schema.properties;
if (props && typeof props === 'object') for (const [name, prop] of Object.entries(props)) {
if (prop && typeof prop === 'object' && (prop as Record<string, unknown>).writeOnly === true) writeOnly.add(name);
}
if (Array.isArray(schema.allOf)) for (const branch of schema.allOf) {
if (branch && typeof branch === 'object') collect(branch as Record<string, unknown>);
}
};
collect(rec);
const required = rec.required;
if (Array.isArray(required)) {
const kept = required.filter((name) => {
return !writeOnly.has(name as string);
});
if (kept.length === 0) delete rec.required;
else rec.required = kept;
}
// Recurse only into schema-bearing keywords. Walking every value reached
// into const/enum/default/examples literal data and rewrote it (review on
// #141).
for (const key of SCHEMA_SINGLE_KEYS) stripWriteOnlyRequired(rec[key]);
for (const key of SCHEMA_ARRAY_KEYS) {
const value = rec[key];
if (Array.isArray(value)) for (const sub of value) stripWriteOnlyRequired(sub, key === 'allOf' ? writeOnly : new Set());
}
for (const key of SCHEMA_MAP_KEYS) {
const value = rec[key];
if (value && typeof value === 'object' && !Array.isArray(value)) {
for (const sub of Object.values(value)) stripWriteOnlyRequired(sub);
}
}
}

Check notice on line 655 in src/manifest.ts

View check run for this annotation

codefactor.io / CodeFactor

src/manifest.ts#L619-L655

Complex Method

/** Literal JSON values are data, even when they contain schema keyword names. */
const SCHEMA_LITERAL_KEYS = new Set(['const', 'enum', 'default', 'example', 'examples']);
Expand Down Expand Up @@ -698,156 +704,156 @@
return dereferenceSchema(value, chain, budget, depth, onUnresolved, refSiblings);
}

function dereferenceSchema(node: unknown, refChain: Set<string>, budget: SchemaBudget, depth = 0, onUnresolved?: (ref: string) => void, refSiblings = false): unknown {
if (Array.isArray(node)) {
charge(budget, 2); // brackets
if (depth > MAX_SCHEMA_DEPTH) return [];
const out: unknown[] = [];
for (const value of node) {
if (out.length) charge(budget, 1); // comma
out.push(dereferenceSchema(value, refChain, budget, depth + 1, onUnresolved, refSiblings));
}
return out;
}
if (!node || typeof node !== 'object') {
charge(budget, jsonLength(node, budget));
return node;
}
if (depth > MAX_SCHEMA_DEPTH) {
charge(budget, 2);
return {};
}

let target = node as Record<string, unknown>;
const ref = typeof target.$ref === 'string' ? target.$ref : undefined;
let chain = refChain;
if (ref) {
if (refChain.has(ref)) {
charge(budget, 2);
return {};
}
const resolved = resolveDocRef(target) as Record<string, unknown>;
if (resolved === target) {
// The walk failed: the pointer does not resolve (#112). Never
// advertise the empty collapse as a schema - the caller treats the
// shape as unknown and surfaces the ref instead.
onUnresolved?.(ref);
charge(budget, 2);
return {};
}
chain = new Set(refChain);
chain.add(ref);
if (refSiblings) {
const siblings = Object.fromEntries(Object.entries(target).filter(([key]) => key !== '$ref'));
if (Object.keys(siblings).length > 0) {
// JSON Schema 2020-12 applies $ref siblings as constraints, not overrides.
// Keep the intersection explicit: overlapping properties, enum, limits,
// and required arrays must all hold, not last-write-wins.
target = { allOf: [resolved, siblings] };
} else target = resolved;
} else target = resolved;
}

charge(budget, 2); // braces
const out: Record<string, unknown> = {};
let first = true;
// Do not materialize Object.entries(target): components may have thousands
// of properties, most of which we will never need to visit.
for (const key in target) {
if (!Object.hasOwn(target, key) || key === '$ref' || key === 'xml' || key === 'discriminator' || key === 'externalDocs' || key === 'nullable') continue;
charge(budget, (first ? 0 : 1) + jsonLength(key, budget) + 1);
first = false;
setOwn(out, key, dereferenceSchemaValue(key, target[key], chain, budget, depth + 1, onUnresolved, refSiblings));
}
if (target.nullable === true && typeof out.type === 'string') {
// Replace the already-counted scalar type with its union representation.
const nullableType = [out.type, 'null'];
charge(budget, JSON.stringify(nullableType).length - JSON.stringify(out.type).length);
out.type = nullableType;
} else if (target.nullable === true && Array.isArray(out.anyOf)) {
// A collapsed multi-type union carries nullability on the anyOf shell
// (#81); append the null branch rather than dropping it.
const nullBranch = { type: 'null' };
charge(budget, 1 + jsonLength(nullBranch, budget));
out.anyOf = [...out.anyOf, nullBranch];
}
return out;
}

Check notice on line 781 in src/manifest.ts

View check run for this annotation

codefactor.io / CodeFactor

src/manifest.ts#L707-L781

Complex Method

/** Prefer specific 2xx codes, then the 2XX range, then default. */
function successJsonSchema(op: OperationInfo, doc: OpenAPIV3.Document, onUnresolved?: (ref: string) => void): Record<string, unknown> | undefined {
const codes = Object.keys(op.responses ?? {})
.filter((c) => /^2\d\d$/.test(c) || /^2XX$/i.test(c) || c === 'default')
.sort((a, b) => {
const rank = (code: string) => /^2\d\d$/.test(code) ? 0 : /^2XX$/i.test(code) ? 1 : 2;
return rank(a) - rank(b) || a.localeCompare(b);
});
// MCP advertises one outputSchema per tool, so a schema is only honest
// when every success status that can return JSON agrees on the shape.
// A valid 201 must never fail client-side validation against a 200-only
// schema. Statuses without a JSON schema produce no structured content
// and cannot violate; a status whose schema is over budget has an unknown
// shape, which cannot be proven equal - advertise nothing rather than
// risk rejecting a valid success for a status we did not validate.
const shapes: Record<string, unknown>[] = [];
let unresolvedRef = false;
const trackUnresolved = (ref: string): void => {
unresolvedRef = true;
onUnresolved?.(ref);
};
for (const code of codes) {
const content = op.responses[code]?.content ?? {};
// Every declared successful representation must be able to satisfy the
// advertised schema (#72), across statuses and media alternatives:
// - a bodyless success (204 No Content) returns no structured content;
// - a status committed to non-JSON media (or wildcard `* /*`, which
// declares no commitment - #65) can validly answer non-JSON;
// - a JSON media alternative without a schema admits any shape;
// - a non-JSON media alternative alongside JSON can be the actual
// response while the schema describes only the JSON one;
// - JSON media alternatives under one status (application/json,
// application/problem+json, vendor +json types) can each be the
// actual response, so every one of them must agree (#76).
// In each case advertising would reject a valid success, so the tool
// stays text-only.
const mediaTypes = Object.keys(content);
if (mediaTypes.length === 0) return undefined;
const jsonMedia = mediaTypes.filter(isJsonMediaType);
if (jsonMedia.length === 0) return undefined;
if (jsonMedia.length !== mediaTypes.length) return undefined;
for (const mediaType of jsonMedia) {
// Forge resolves top-level response $refs before exposing OperationInfo,
// which discards 3.1 sibling constraints. Read that schema from the
// loaded document only when it actually has siblings to preserve.
let schema = content[mediaType]?.schema as Record<string, unknown> | undefined;
if (doc.openapi.startsWith('3.1.')) {
const sourceResponses = doc.paths[op.path]?.[op.method as OpenAPIV3.HttpMethods]?.responses;
const rawResponse = resolveDocRef(sourceResponses?.[code]) as OpenAPIV3.ResponseObject | undefined;
const loaded = rawResponse?.content?.[mediaType]?.schema as Record<string, unknown> | undefined;
if (loaded && '$ref' in loaded && Object.keys(loaded).length > 1) schema = loaded;
}
if (!schema || typeof schema !== 'object' || Object.keys(schema).length === 0) return undefined;
let dereferenced: Record<string, unknown>;
try {
dereferenced = dereferenceSchema(schema, new Set(), { remaining: DEREFERENCE_BYTE_BUDGET }, 0, trackUnresolved, doc.openapi.startsWith('3.1.')) as Record<string, unknown>;
} catch (error) {
if (error instanceof SchemaTooLarge) return undefined;
throw error;
}
// An unresolved $ref collapses to an open schema (#112); advertising
// it would accept everything, so the tool stays text-only and the
// ref is surfaced through onUnresolved.
if (unresolvedRef) return undefined;
shapes.push(dereferenced);
}
}
if (shapes.length === 0) return undefined;
const first = JSON.stringify(shapes[0]);
for (const shape of shapes.slice(1)) {
if (JSON.stringify(shape) !== first) return undefined;
}
return shapes[0];
}

Check notice on line 856 in src/manifest.ts

View check run for this annotation

codefactor.io / CodeFactor

src/manifest.ts#L784-L856

Complex Method

function isNonObjectValue(value: unknown): boolean {
return value === null || Array.isArray(value) || typeof value !== 'object';
Expand Down Expand Up @@ -982,25 +988,25 @@
* Presence-triggered keywords (dependencies, propertyNames,
* additionalProperties) cannot fail on {} and are ignored. Unresolvable or
* cyclic references fail closed. */
function emptyObjectSatisfies(schema: Record<string, unknown> | undefined, seen: Set<unknown> = new Set()): boolean {
if (!schema || typeof schema !== 'object' || seen.has(schema)) return false;
seen.add(schema);
if (Array.isArray(schema.required) && schema.required.length > 0) return false;
if (typeof schema.minProperties === 'number' && schema.minProperties > 0) return false;
// enum/const constrain the instance regardless of properties: {} must be a
// member, which for JSON Schema equality means exactly the empty object.
const isEmptyPlainObject = (value: unknown): boolean =>
typeof value === 'object' && value !== null && !Array.isArray(value) && Object.keys(value).length === 0;
if (schema.const !== undefined && !isEmptyPlainObject(schema.const)) return false;
if (schema.enum !== undefined && (!Array.isArray(schema.enum) || !schema.enum.some(isEmptyPlainObject))) return false;
if (schema.anyOf !== undefined || schema.oneOf !== undefined || schema.not !== undefined || schema.if !== undefined) return false;
if (Array.isArray(schema.allOf)) {
for (const branch of schema.allOf) {
if (!emptyObjectSatisfies(resolveDocRef(branch) as Record<string, unknown> | undefined, seen)) return false;
}
}
return true;
}

Check notice on line 1009 in src/manifest.ts

View check run for this annotation

codefactor.io / CodeFactor

src/manifest.ts#L991-L1009

Complex Method

/** Content types declared on success (2xx or default) responses, in spec order. */
function collectResponseContentTypes(op: OperationInfo): string[] {
Expand Down Expand Up @@ -1036,349 +1042,349 @@
const toolNames = dedupeNames(selectedIds.map(toToolName));
const tools: ToolDef[] = [];

selectedIds.forEach((operationId, i) => {
const op = resolveOperation(operationId);
const toolName = toolNames[i];
if (!op || !toolName) return;

const args: ToolArg[] = [];
const nullableParents: { arg: ToolArg; node: Record<string, unknown> }[] = [];
for (const p of op.pathParams) {
const media = Object.keys(sourceParameter(doc, op, p.name, 'path')?.content ?? {});
args.push({ name: p.name, location: 'path', ...(media.length === 1 ? { parameterContentType: media[0] } : {}), required: true, schema: parameterArgSchema(doc, op, p, 'path', warnings), ...(p.style !== undefined ? { style: p.style } : {}), ...(p.explode !== undefined ? { explode: p.explode } : {}), ...(p.allowReserved !== undefined ? { allowReserved: p.allowReserved } : {}) });
}
// A path placeholder with no declared parameter would otherwise stay
// literal in the URL and hit the upstream as "{id}" (#152). Synthesize
// a required string argument for every undeclared placeholder, loudly.
const declaredPath = new Set(args.filter((a) => a.location === 'path').map((a) => a.name));
for (const match of op.path.matchAll(/\{([^}]+)\}/g)) {
const placeholder = match[1]!;
if (declaredPath.has(placeholder)) continue;
declaredPath.add(placeholder);
warnings.push(`${toolName}: path placeholder "${placeholder}" is not declared as a parameter; synthesized a required string argument (#152)`);
args.push({ name: placeholder, location: 'path', required: true, schema: { type: 'string' } });
}
// Inverse consistency (#211): a declared path parameter that matches no
// placeholder can never be substituted into the URL. Surface it in the
// same warning class instead of silently carrying a dead argument.
const placeholders = new Set([...op.path.matchAll(/\{([^}]+)\}/g)].map((m) => m[1]));
for (const p of op.pathParams) {
if (!placeholders.has(p.name)) {
warnings.push(`${toolName}: declared path parameter "${p.name}" has no matching placeholder in the path (#211)`);
}
}
for (const p of op.queryParams) {
const media = Object.keys(sourceParameter(doc, op, p.name, 'query')?.content ?? {});
args.push({ name: p.name, location: 'query', ...(media.length === 1 ? { parameterContentType: media[0] } : {}), required: p.required, schema: parameterArgSchema(doc, op, p, 'query', warnings), ...(p.style !== undefined ? { style: p.style } : {}), ...(p.explode !== undefined ? { explode: p.explode } : {}), ...(p.allowReserved !== undefined ? { allowReserved: p.allowReserved } : {}) });
}
for (const p of op.headerParams) {
const media = Object.keys(sourceParameter(doc, op, p.name, 'header')?.content ?? {});
args.push({ name: p.name, location: 'header', ...(media.length === 1 ? { parameterContentType: media[0] } : {}), required: p.required, schema: parameterArgSchema(doc, op, p, 'header', warnings), ...(p.style !== undefined ? { style: p.style } : {}), ...(p.explode !== undefined ? { explode: p.explode } : {}), ...(p.allowReserved !== undefined ? { allowReserved: p.allowReserved } : {}) });
}
for (const p of cookieParameters(doc, op)) {
// Cookie parameters always serialize with form style; record it
// explicitly so the runtime does not fall back to simple.
const media = Object.keys(p.content ?? {});
args.push({ name: p.name, location: 'cookie', ...(media.length === 1 ? { parameterContentType: media[0] } : {}), required: p.required === true, schema: cookieArgSchema(p, warnings), style: p.style ?? 'form', ...(p.explode !== undefined ? { explode: p.explode } : {}) });
}

const contentType = op.hasRequestBody ? pickContentType(op.requestContentTypes) : undefined;

// Schema.required applies only when a body is present. The OpenAPI
// requestBody.required flag controls whether any body is needed at all.
const bodyRequired = (normalizedRequestBody(doc.paths[op.path]?.[op.method as OpenAPIV3.HttpMethods]?.requestBody) as OpenAPIV3.RequestBodyObject | undefined)?.required === true;
const rootRequired = new Set(op.requestBodyRequired);
// A required object body where {} is invalid for reasons the flattened
// arguments cannot express (minProperties, composition keywords) must not
// invent an empty body: fall back to a required whole-body argument whose
// dereferenced schema keeps the constraints. Root-level required
// properties are left to the flattening path, which already requires them.
const emptyBodyFallback = bodyRequired && contentType !== undefined && isJsonMediaType(contentType) && (() => {
const body = normalizedRequestBody(doc.paths[op.path]?.[op.method as OpenAPIV3.HttpMethods]?.requestBody) as OpenAPIV3.RequestBodyObject | undefined;
const schema = resolveDocRef(body?.content?.[contentType]?.schema) as Record<string, unknown> | undefined;
if (schema?.type !== 'object') return false;
if (Array.isArray(schema.required) && schema.required.length > 0) return false;
return !emptyObjectSatisfies(schema);
})();
const wholeBodyRequired = contentType !== undefined && isJsonMediaType(contentType) && (missingRequiredBodyField(doc, op, contentType) || emptyBodyFallback);
let wholeBodySchema: Record<string, unknown> | undefined;
if (wholeBodyRequired) {
const node = resolveDocRef((normalizedRequestBody(doc.paths[op.path]?.[op.method as OpenAPIV3.HttpMethods]?.requestBody) as OpenAPIV3.RequestBodyObject)?.content?.[contentType]?.schema);
try {
wholeBodySchema = pinBodyDiscriminator(node, dereferenceSchema(node, new Set(), { remaining: DEREFERENCE_BYTE_BUDGET }) as Record<string, unknown>);
} catch (error) {
if (!(error instanceof SchemaTooLarge)) throw error;
warnings.push(`${toolName}: required body properties cannot be flattened and the whole-body schema exceeds the input budget; using an unconstrained body argument`);
wholeBodySchema = {};
}
}
if (contentType && isJsonMediaType(contentType) && contentType !== 'application/json') {
const body = normalizedRequestBody(doc.paths[op.path]?.[op.method as OpenAPIV3.HttpMethods]?.requestBody) as OpenAPIV3.RequestBodyObject | undefined;
const schema = resolveDocRef(body?.content?.[contentType]?.schema) as Record<string, unknown> | undefined;
const collect = (node: Record<string, unknown> | undefined): void => {
if (!node) return;
if (Array.isArray(node.required)) for (const name of node.required) if (typeof name === 'string') rootRequired.add(name);
if (Array.isArray(node.allOf)) for (const branch of node.allOf) collect(resolveDocRef(branch) as Record<string, unknown>);
};
collect(schema);
}
if (wholeBodyRequired) {
args.push({ name: 'body', location: 'body', apiFieldPath: [], required: bodyRequired, schema: wholeBodySchema! });
} else if (op.requestBodyIsArray) {
const requestBody = normalizedRequestBody(doc.paths[op.path]?.[op.method as OpenAPIV3.HttpMethods]?.requestBody) as OpenAPIV3.RequestBodyObject | undefined;
const raw = resolveDocRef(requestBody?.content?.[contentType ?? 'application/json']?.schema);
let schema: Record<string, unknown>;
try {
schema = dereferenceSchema(raw, new Set(), { remaining: DEREFERENCE_BYTE_BUDGET }) as Record<string, unknown>;
} catch (error) {
if (!(error instanceof SchemaTooLarge)) throw error;
warnings.push(`${toolName}: array body schema exceeds the input budget; using an unconstrained array argument`);
schema = { type: 'array', items: {} };
}
args.push({
name: 'body',
location: 'body',
apiFieldPath: [],
required: bodyRequired,
schema: { ...schema, description: schema.description ?? op.requestBodyDescription ?? 'Request body (JSON array).' },
});
} else if (op.bodyParams.length > 0) {
for (const p of op.bodyParams) {
const argName = p.apiFieldPath.join('.');
// A leaf is required at tool level when it is required within its
// parent and its root field is required at body level. Nested optional
// parents make this an approximation - documented in the README.
const required = bodyRequired && p.required && rootRequired.has(p.apiFieldPath[0] ?? '');
// Nullable is not optional (#81): a required property may still be
// null, so wrap the adapted view rather than loosening `required`.
const leafSchema = restoreIntegerType(argSchema(p), bodySchemaAtPath(doc, op, p.apiFieldPath));
const schema = bodyPropertyNullable(doc, op, p.apiFieldPath)
? { anyOf: [leafSchema, { type: 'null' }] }
: leafSchema;
args.push({ name: argName, location: 'body', apiFieldPath: p.apiFieldPath, required, schema });
}
// A nullable object parent flattens to leaves in Forge's view, so
// null for the parent is unsendable and {"pet": null} silently goes
// out as an empty body (#120). Retain a parent arg accepting
// object|null alongside the leaf args. Parent args come after the
// leaves, so on the wire a provided parent replaces its subtree; a
// provided leaf with an absent parent still builds the object.
// Nested parents stay optional: required-ness within an optional
// parent is the same approximation the leaves document.
const seenParents = new Set<string>();
for (const p of op.bodyParams) {
for (let depth = 1; depth < p.apiFieldPath.length; depth++) {
const prefix = p.apiFieldPath.slice(0, depth);
const key = prefix.join('.');
if (seenParents.has(key)) continue;
seenParents.add(key);
const node = bodySchemaAtPath(doc, op, prefix);
if (!node || node.nullable !== true) continue;
if (node.type !== 'object' && !node.properties && !Array.isArray(node.allOf)) continue;
// The object branch carries the full dereferenced shape (minus
// the nullable marker), so "pet.id required when pet is an
// object" is enforced inside the parent route too (#122). A
// schema too large to inline falls back to the coarse object
// branch, matching argSchema's coarseness elsewhere.
let objectBranch: Record<string, unknown> = { type: 'object' };
try {
const dereferenced = dereferenceSchema(node, new Set(), { remaining: DEREFERENCE_BYTE_BUDGET }) as Record<string, unknown>;
if (dereferenced && typeof dereferenced === 'object') objectBranch = dereferenced;
// The null sibling covers nullability; the object branch must
// admit objects only, or its required properties would also
// demand them on a null value.
if (Array.isArray(objectBranch.type)) {
const nonNull = objectBranch.type.filter((t) => t !== 'null');
objectBranch.type = nonNull.length === 1 ? nonNull[0] : nonNull;
}
delete objectBranch.nullable;
} catch {
// SchemaTooLarge: keep the coarse branch.
}
const arg: ToolArg = {
name: key,
location: 'body',
apiFieldPath: prefix,
required: depth === 1 ? bodyRequired && rootRequired.has(prefix[0] ?? '') : false,
schema: {
anyOf: [objectBranch, { type: 'null' }],
...(typeof node.description === 'string' ? { description: node.description } : {}),
},
};
args.push(arg);
nullableParents.push({ arg, node });
}
}
} else if (contentType === 'multipart/form-data' && op.multipart) {
const body = normalizedRequestBody(doc.paths[op.path]?.[op.method as OpenAPIV3.HttpMethods]?.requestBody) as OpenAPIV3.RequestBodyObject | undefined;
const encodings = body?.content?.['multipart/form-data']?.encoding;
for (const field of op.multipart.fields) {
const arg = multipartFieldArg(field, body, warnings);
const declaredMime = encodings?.[field.name]?.contentType;
if (arg.binary && typeof declaredMime === 'string' && declaredMime.trim()) {
arg.defaultMimeType = declaredMime.trim();
const fileSchema = arg.binaryArray ? arg.schema.items as Record<string, unknown> : arg.schema;
const mimeSchema = (fileSchema.properties as Record<string, Record<string, unknown>>).mimeType;
mimeSchema!.description = `File MIME type (default: ${arg.defaultMimeType}).`;
}
if (!arg.binary && typeof declaredMime === 'string' && declaredMime.trim()) {
arg.partContentType = declaredMime.trim();
}
arg.required = bodyRequired && arg.required;
args.push(arg);
}
} else if (op.hasRequestBody) {
// A primitive JSON root is a raw body, but its source constraints still
// govern the tool argument. Non-JSON/free-form bodies retain the old view.
let rawSchema: Record<string, unknown> | undefined;
if (contentType && isJsonMediaType(contentType)) {
const body = normalizedRequestBody(doc.paths[op.path]?.[op.method as OpenAPIV3.HttpMethods]?.requestBody) as OpenAPIV3.RequestBodyObject | undefined;
const node = body?.content?.[contentType]?.schema;
if (node && typeof node === 'object') {
let unresolved = false;
try {
const schema = dereferenceSchema(node, new Set(), { remaining: DEREFERENCE_BYTE_BUDGET }, 0,
() => { unresolved = true; }, doc.openapi.startsWith('3.1.')) as Record<string, unknown>;
if (!unresolved) rawSchema = schema;
else warnings.push(`${toolName}: raw JSON body schema has an unresolved reference; using an unconstrained body argument`);
} catch (error) {
if (!(error instanceof SchemaTooLarge)) throw error;
warnings.push(`${toolName}: raw JSON body schema exceeds the input budget; using an unconstrained body argument`);
}
}
}
args.push({
name: 'body',
location: 'body',
apiFieldPath: [],
required: bodyRequired,
schema: rawSchema ? { ...rawSchema, description: rawSchema.description ?? op.requestBodyDescription ?? 'Raw request body.' }
: contentType === 'application/x-www-form-urlencoded'
? { type: 'object', description: op.requestBodyDescription ?? 'Form fields; array serialization follows the spec encoding.' }
: contentType && (contentType === 'application/xml' || contentType === 'text/xml' || contentType.endsWith('+xml'))
? { type: 'string', description: op.requestBodyDescription ?? 'Pre-serialized XML request body.' }
: { description: op.requestBodyDescription ?? 'Raw request body.' },
});
}

disambiguateArgNames(args);

const properties: Record<string, unknown> = {};
const requiredArgs: string[] = [];
for (const a of args) {
setOwn(properties, a.name, a.schema);
if (a.required) requiredArgs.push(a.name);
}
// A required nullable parent with required leaves cannot sit in
// `required` flatly: requiring both pet and pet.id makes the null
// branch inaccessible (#122). The runtime's coverage rule accepts
// either route (parent arg or leaf args), so the input schema says the
// same with conditional clauses: presence is the parent OR any
// descendant, and each required descendant is itself OR an ancestor.
// This applies at every depth (#133): a nested nullable parent is
// scoped to its own field path, and because required-ness chains down
// through required arrays, a required descendant proves the parent's
// presence is required too.
const conditional: Record<string, unknown>[] = [];
const bodyArgs = args.filter((a) => a.location === 'body' && a.apiFieldPath && a.apiFieldPath.length > 0);
for (const { arg: parent } of nullableParents) {
const parentPath = parent.apiFieldPath!;
const subtree = bodyArgs.filter((a) =>
a !== parent &&
a.apiFieldPath!.length > parentPath.length &&
parentPath.every((seg, i) => a.apiFieldPath![i] === seg));
if (subtree.length === 0) continue;
if (!parent.required && !subtree.some((a) => a.required)) continue;
const drop = new Set([parent.name, ...subtree.filter((a) => a.required).map((a) => a.name)]);
for (let i = requiredArgs.length - 1; i >= 0; i--) {
if (drop.has(requiredArgs[i]!)) requiredArgs.splice(i, 1);
}
conditional.push({ anyOf: [parent, ...subtree].map((a) => ({ required: [a.name] })) });
for (const leaf of subtree) {
if (!leaf.required) continue;
const ancestors = [parent, ...subtree.filter((a) => a !== leaf && leaf.apiFieldPath!.length > a.apiFieldPath!.length && a.apiFieldPath!.every((seg, i) => leaf.apiFieldPath![i] === seg))];
conditional.push({ anyOf: [leaf, ...ancestors].map((a) => ({ required: [a.name] })) });
}
}

const pathItem = doc.paths[op.path] as OpenAPIV3.PathItemObject | undefined;
const operation = pathItem?.[op.method as OpenAPIV3.HttpMethods] as OpenAPIV3.OperationObject | undefined;
const scopedServer = operation?.servers?.[0] ?? pathItem?.servers?.[0];
const authSelection = forOperation(
doc.paths[op.path]?.[op.method as OpenAPIV3.HttpMethods] as OpenAPIV3.OperationObject ?? {},
`${op.method.toUpperCase()} ${op.path}`,
);

const description =
op.description.split('\n')[0]?.trim() || `${op.method.toUpperCase()} ${op.path}`;

const tool: ToolDef = {
name: toolName,
description,
operationId,
tags: tagsById.get(operationId) ?? [],
method: op.method.toUpperCase(),
path: op.path,
...(opts.baseUrl === undefined && scopedServer ? { baseUrl: serverUrl(scopedServer) } : {}),
args,
authSchemeNames: authSelection.names,
// Emitted only with more than one OR alternative (#146); single-route
// tools keep the legacy shape and older runtimes read authSchemeNames.
...(authSelection.alternatives.length > 1 ? { authAlternatives: authSelection.alternatives } : {}),
inputSchema: {
type: 'object',
properties,
required: requiredArgs,
additionalProperties: false,
...(conditional.length > 0 ? { allOf: conditional } : {}),
},
};
if (contentType) tool.contentType = contentType;
if (op.requestBodyIsArray) tool.requestBodyIsArray = true;
if (bodyRequired && contentType !== undefined && (isJsonMediaType(contentType) || contentType === 'multipart/form-data') &&
!args.some((arg) => arg.location === 'body' && arg.required)) {
const requestBody = normalizedRequestBody(doc.paths[op.path]?.[op.method as OpenAPIV3.HttpMethods]?.requestBody) as OpenAPIV3.RequestBodyObject | undefined;
const schema = resolveDocRef(requestBody?.content?.[contentType]?.schema) as Record<string, unknown> | undefined;
if (schema?.type === 'object' && emptyObjectSatisfies(schema)) tool.requiredEmptyObject = true;
}
if (contentType === 'application/x-www-form-urlencoded') {
const requestBody = normalizedRequestBody(doc.paths[op.path]?.[op.method as OpenAPIV3.HttpMethods]?.requestBody) as OpenAPIV3.RequestBodyObject | undefined;
const encodings = requestBody?.content?.[contentType]?.encoding;
if (encodings && Object.keys(encodings).length > 0) {
tool.formEncoding = Object.fromEntries(Object.entries(encodings).map(([name, encoding]) => [name, {
...(encoding.style !== undefined ? { style: encoding.style } : {}),
...(encoding.explode !== undefined ? { explode: encoding.explode } : {}),
...(encoding.contentType !== undefined || encoding.allowReserved !== undefined
? { unsupported: 'contentType or allowReserved' } : {}),
}]));
}
}


const responseSchema = successJsonSchema(op, doc, (ref) => {
warnings.push(`${toolName}: response schema $ref "${ref}" could not be resolved; the tool stays text-only (#112)`);
});
if (responseSchema) {
stripWriteOnlyRequired(responseSchema);
// The advertised top-level type must be the literal "object" (MCP
// clients validate it). MCP requires object-shaped structured
// content, so arrays, primitives and root-nullable schemas (#92: a
// valid JSON null cannot be emitted unwrapped) go under a single
// "result" property; plain object schemas advertise as-is.
const wrap = !schemaIsObject(responseSchema) || schemaAdmitsNonObjectRoot(responseSchema);
const candidate = wrap
? { type: 'object', properties: { result: responseSchema }, required: ['result'] }
: { ...responseSchema, type: 'object' };
if (JSON.stringify(candidate).length <= MAX_OUTPUT_SCHEMA_BYTES) {
tool.outputSchema = candidate;
if (wrap) tool.outputWrap = true;
}
// Over-budget schemas (huge generated component trees) stay text-only.
}
const responseContentTypes = collectResponseContentTypes(op);
if (responseContentTypes.length > 0) tool.responseContentTypes = responseContentTypes;
tools.push(tool);
});

Check failure on line 1387 in src/manifest.ts

View check run for this annotation

codefactor.io / CodeFactor

src/manifest.ts#L1045-L1387

Very Complex Method

const manifest: Manifest = {
generator: `spec2mcp`,
Expand Down
58 changes: 58 additions & 0 deletions test/parameter-composition-root.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,61 @@ for (const location of ['path', 'query', 'header', 'cookie']) {
assert.equal(validate({ v: 'safe' }), false);
});
}

for (const location of ['path', 'query', 'header', 'cookie']) {
test(`primitive ${location} parameters retain allOf constraints alongside anyOf`, async () => {
const doc = { openapi: '3.1.0', info: { title: 'AllOf intersection', version: '1' }, components: { schemas: {} },
paths: { [location === 'path' ? '/x/{v}' : '/x']: { get: { operationId: 'x', parameters: [{ name: 'v', in: location, required: true, schema: {
allOf: [{ type: 'integer', minimum: 5 }], anyOf: [{ type: 'integer' }, { type: 'string' }],
} }], responses: { '200': { description: 'OK' } } } } },
} as unknown as OpenAPIV3.Document;
await init(doc);
const tool = buildManifest(doc).tools[0]!;
const validate = compileOutputValidator(JSON.parse(JSON.stringify(tool.inputSchema)));
assert.equal(validate({ v: 5 }), true);
assert.equal(validate({ v: 4 }), false);
assert.equal(validate({ v: 'safe' }), false);
});
}

for (const [label, source, expected] of [
['allOf-only enum default', { allOf: [{ $ref: '#/components/schemas/Enum' }], default: 'safe' }, { type: 'string', enum: ['safe'], default: 'safe' }],
['allOf-only nullable', { nullable: true, allOf: [{ type: 'string' }] }, { type: ['string', 'null'] }],
] as const) {
test(`query parameter preserves the existing flat view for ${label}`, async () => {
const doc = { openapi: '3.0.3', info: { title: 'Old allOf', version: '1' }, components: { schemas: { Enum: { type: 'string', enum: ['safe'] } } },
paths: { '/x': { get: { operationId: 'x', parameters: [{ name: 'v', in: 'query', schema: source }], responses: { '200': { description: 'OK' } } } } },
} as unknown as OpenAPIV3.Document;
await init(doc);
assert.deepEqual(buildManifest(doc).tools[0]!.args[0]!.schema, expected);
});
}

test('composed query parameter retains default and nullable without branch format', async () => {
const doc = { openapi: '3.0.3', info: { title: 'Composed nullable', version: '1' }, components: { schemas: {} }, paths: {
'/x': { get: { operationId: 'x', parameters: [{ name: 'v', in: 'query', schema: {
nullable: true, default: 'safe', allOf: [{ type: 'string', format: 'custom' }], anyOf: [{ type: 'string', pattern: '^safe$' }],
} }], responses: { '200': { description: 'OK' } } } },
} } as unknown as OpenAPIV3.Document;
await init(doc);
const tool = buildManifest(doc).tools[0]!;
assert.equal(tool.args[0]!.schema.default, 'safe');
assert.ok(!JSON.stringify(tool.args[0]!.schema).includes('format'));
const validate = compileOutputValidator(JSON.parse(JSON.stringify(tool.inputSchema)));
assert.equal(validate({ v: null }), true);
assert.equal(validate({ v: 'safe' }), true);
assert.equal(validate({ v: 'unsafe' }), false);
});

for (const keyword of ['anyOf', 'oneOf']) {
test(`${keyword}-only primitive branches retain their existing format annotation`, async () => {
const doc = { openapi: '3.1.0', info: { title: 'Branch formats', version: '1' }, components: { schemas: {} }, paths: {
'/x': { get: { operationId: 'x', parameters: [{ name: 'v', in: 'query', schema: {
[keyword]: [{ type: 'string', format: 'uuid' }, { type: 'integer' }],
} }], responses: { '200': { description: 'OK' } } } },
} } as unknown as OpenAPIV3.Document;
await init(doc);
const schema = buildManifest(doc).tools[0]!.args[0]!.schema;
assert.equal((schema[keyword] as Record<string, unknown>[])[0]!.format, 'uuid');
});
}
Loading