Tool Schema Builder — TypeScript source
Build function-calling and MCP tool schemas that pass strict mode on the first try, and lint pasted ones against the strict-mode contract — additionalProperties, required-sync, defaults, enums — with one-click autofix for every mechanical violation. Runs entirely in your browser.
This is the TypeScript implementation — the same logic the interactive tool runs, in a shareable, citable form.
// tool-schema-builder (FEAT-029) — strict-mode JSON Schema rules for
// function-calling / MCP tool definitions. Pure logic: validate a pasted
// definition against the strict-mode contract, autofix the mechanical
// violations, or build a compliant definition from param rows. The rule
// table in the tool README mirrors these IDs one-to-one (the lint output
// IS the answer surface). Mirrored in Go at cli/tool-schema-builder.
export type SupportedType = 'string' | 'number' | 'integer' | 'boolean' | 'object' | 'array';
export const SUPPORTED_TYPES: readonly SupportedType[] = [
'string', 'number', 'integer', 'boolean', 'object', 'array',
];
export interface Issue {
rule:
| 'json-parseable'
| 'non-empty-name'
| 'description-present'
| 'no-additional-properties'
| 'all-required'
| 'no-defaults'
| 'typed-properties'
| 'enum-values'
| 'array-items';
path: string;
message: string;
}
export interface ValidateResult {
parseError?: string;
issues: Issue[];
}
type Obj = Record<string, unknown>;
const asObj = (v: unknown): Obj | null =>
v !== null && typeof v === 'object' && !Array.isArray(v) ? (v as Obj) : null;
function checkObject(path: string, obj: Obj, issues: Issue[]): void {
if (obj.additionalProperties !== false) {
issues.push({
rule: 'no-additional-properties',
path,
message: `${path}: set additionalProperties: false (strict mode requires it on every object)`,
});
}
const props = asObj(obj.properties);
const keys = props ? Object.keys(props) : [];
const required = Array.isArray(obj.required) ? obj.required : [];
if (keys.some((k) => !required.includes(k))) {
issues.push({
rule: 'all-required',
path,
message: `${path}: required must list every property (${keys.filter((k) => !required.includes(k)).join(', ')} missing)`,
});
}
for (const key of keys) {
const propPath = `${path}.properties.${key}`;
const prop = asObj(props?.[key]);
if (!prop) continue;
if ('default' in prop) {
issues.push({
rule: 'no-defaults',
path: propPath,
message: `${propPath}: strict mode rejects defaults — remove the default key`,
});
}
const desc = prop.description;
if (typeof desc !== 'string' || desc.trim().length === 0) {
issues.push({
rule: 'description-present',
path: propPath,
message: `${propPath}: every property needs a description`,
});
}
const type = prop.type;
if (typeof type !== 'string' || !SUPPORTED_TYPES.includes(type as SupportedType)) {
issues.push({
rule: 'typed-properties',
path: propPath,
message: `${propPath}: property type must be one of ${SUPPORTED_TYPES.join(' | ')}`,
});
}
if (Array.isArray(prop.enum)) {
if (prop.enum.length === 0) {
issues.push({ rule: 'enum-values', path: propPath, message: `${propPath}: enum must not be empty` });
} else {
const kinds = new Set(prop.enum.map((v) => typeof v));
if (kinds.size > 1 || kinds.has('object') || kinds.has('undefined')) {
issues.push({ rule: 'enum-values', path: propPath, message: `${propPath}: enum values must share one primitive type` });
}
}
}
if (type === 'array' && !asObj(prop.items)) {
issues.push({ rule: 'array-items', path: propPath, message: `${propPath}: arrays must declare items with a type` });
}
if (type === 'object' && prop.properties !== undefined) {
const nested = asObj(prop.properties) ? prop : null;
if (nested) checkObject(propPath, nested, issues);
}
}
}
/** Validate a JSON string of a full tool definition ({name, description, input_schema}). */
export function validateToolSchema(input: string): ValidateResult {
let parsed: unknown;
try {
parsed = JSON.parse(input);
} catch (e) {
return { parseError: e instanceof Error ? e.message : String(e), issues: [] };
}
const issues: Issue[] = [];
const root = asObj(parsed);
if (!root) {
return { issues: [{ rule: 'json-parseable', path: '$', message: 'Input must be a JSON object' }] };
}
const name = root.name;
if (typeof name !== 'string' || !/^[a-z0-9_-]{1,64}$/.test(name)) {
issues.push({
rule: 'non-empty-name',
path: 'name',
message: 'name must be 1-64 chars of [a-z0-9_-] (the get_weather convention)',
});
}
const description = root.description;
if (typeof description !== 'string' || description.trim().length === 0) {
issues.push({ rule: 'description-present', path: 'description', message: 'The tool needs a description' });
}
const schema = asObj(root.input_schema);
if (schema && schema.type === 'object') {
checkObject('input_schema', schema, issues);
} else {
issues.push({
rule: 'json-parseable',
path: 'input_schema',
message: 'input_schema must be an object with type: "object"',
});
}
return { issues };
}
export interface AutoStrictResult {
parseError?: string;
schema?: Obj;
fixes: string[];
}
function strictObject(obj: Obj, path: string, fixes: string[]): void {
if (obj.additionalProperties !== false) {
obj.additionalProperties = false;
fixes.push(`added additionalProperties: false at ${path}`);
}
const props = asObj(obj.properties);
if (props) {
const keys = Object.keys(props);
const required = Array.isArray(obj.required) ? (obj.required as unknown[]) : [];
const clean = required.filter((k) => typeof k === 'string' && keys.includes(k));
if (keys.some((k) => !clean.includes(k)) || clean.length !== required.length) {
obj.required = keys;
fixes.push(`synced required at ${path}`);
}
for (const key of keys) {
const prop = asObj(props[key]);
if (!prop) continue;
if ('default' in prop) {
delete prop.default;
fixes.push(`removed default at ${path}.properties.${key}`);
}
if (prop.type === 'object' && asObj(prop.properties)) {
strictObject(prop, `${path}.properties.${key}`, fixes);
}
}
}
}
/** Autofix the mechanical strict-mode violations. Never invents names or copy. Idempotent. */
export function autoStrict(input: string): AutoStrictResult {
let parsed: unknown;
try {
parsed = JSON.parse(input);
} catch (e) {
return { parseError: e instanceof Error ? e.message : String(e), fixes: [] };
}
const root = asObj(parsed);
if (!root) return { fixes: [] };
const fixes: string[] = [];
const schema = asObj(root.input_schema);
if (schema) strictObject(schema, 'input_schema', fixes);
return { schema: root, fixes };
}
export interface ParamDef {
name: string;
type: SupportedType;
description: string;
}
/**
* Build a fully strict definition from param rows; empty names are skipped.
* There is deliberately no per-param "required" toggle — strict mode
* requires every property to be listed in `required`, so the builder
* always emits all of them. Optionality belongs in the model's semantics,
* not the schema.
*/
export function buildToolSchema(def: { name: string; description: string; params: ParamDef[] }): Obj {
const real = def.params.filter((p) => p.name.trim().length > 0);
const properties: Obj = {};
for (const p of real) {
const prop: Obj = { type: p.type, description: p.description.trim() || `${p.name} parameter` };
if (p.type === 'array') prop.items = { type: 'string' };
properties[p.name.trim()] = prop;
}
return {
name: def.name,
description: def.description,
input_schema: {
type: 'object',
properties,
required: real.map((p) => p.name.trim()),
additionalProperties: false,
},
};
}
Also available in 13 other languages
Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →