Skip to content

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 →