A model cannot call a tool it cannot describe. Before an agent can ever invoke search_web or add, something has to tell the model the tool exists, explain what it does, and declare exactly which arguments it accepts. That something is a tool definition — a small, structured description that serves two masters at once. The model reads it to decide whether to call the tool; your code reads it to check that the call the model eventually makes is well-formed.
This lesson takes a definition apart into its three fields, shows what each one is for, and then does the step beginners skip: it validates a proposed call against the definition before that call reaches the real function. Skipping that check is how a missing argument, or a string sent where a number belonged, turns into a crash buried inside a tool the model cannot see — or worse, into a silent wrong answer the model then trusts.
flowchart LR T["tool definition"] --> N["name<br/>the call identifier"] T --> D["description<br/>the model reads this"] T --> S["inputSchema<br/>properties and required"] style N fill:#1d4ed8,color:#fff style D fill:#7c3aed,color:#fff style S fill:#166534,color:#fff
The three fields
A definition carries three fields, and each does a different job. The name is the identifier the model puts in its call — add, search_web — matched exactly, so a typo here means a tool that is never reached. The description is a sentence or two of plain text the model reads when judging whether the tool fits the moment; it is the only pitch the tool gets, so a vague one earns no calls. The inputSchema is a JSON Schema describing the arguments: a properties map naming each argument alongside its type, and a required list naming the arguments a call must supply. The first two fields speak to the model; the schema speaks to the validator you are about to write.
const add = {
name: 'add',
description: 'Add two numbers and return the sum',
inputSchema: {
type: 'object',
properties: { a: { type: 'number' }, b: { type: 'number' } },
required: ['a', 'b']
}
};
function signature(tool) {
return tool.name + '(' + (tool.inputSchema.required || []).join(', ') + ')';
}
console.log(signature(add));
console.log(add.inputSchema.properties.a.type);
Why validate the call at all
The model does its best to fill in arguments, but it is not a type checker. Left unchecked, it will happily send a string where your function expected a number, omit a required argument entirely, or pass null for a field that must exist. If those arguments flow straight into the function, you get a crash buried inside tool code — or, worse, a computation that runs to completion on garbage and returns a confidently wrong result.
The defence is a validator: a function that compares the model's arguments against the schema before dispatch and rejects anything that does not match. The schema already declares what a correct call looks like, so the validator is simply the code that enforces that declaration. One document, two audiences — the model reads the description, your validator reads the schema.
flowchart LR D["one tool definition"] --> M["model reads description<br/>decides whether to call"] D --> V["validator reads inputSchema<br/>checks the call is well-formed"] style M fill:#7c3aed,color:#fff style V fill:#166534,color:#fff
Matching a value to a type
Validation has two halves. Every required argument must be present, and every argument that is present must have the right type. Presence is checked with the in operator — name in args — which is true the moment the key exists, whatever value it holds. Type-checking maps a JSON-Schema type to a JavaScript test: number, string, and boolean are direct typeof checks, but array needs Array.isArray, because in JavaScript an array also reports typeof as 'object'. That quirk — typeof [] === 'object' and typeof null === 'object' — is exactly why a naive checker passes arrays off as objects and lets null slip through as a valid object. The helper below pins each type down precisely.
function matchesType(value, type) {
if (type === 'array') return Array.isArray(value);
if (type === 'number') return typeof value === 'number';
if (type === 'string') return typeof value === 'string';
if (type === 'boolean') return typeof value === 'boolean';
return true;
}
function validateCall(tool, args) {
const schema = tool.inputSchema;
const props = schema.properties || {};
for (const name of schema.required || []) {
if (!(name in args)) return false;
if (!matchesType(args[name], props[name] && props[name].type)) return false;
}
return true;
}
const add = {
name: 'add',
description: 'sum two numbers',
inputSchema: {
type: 'object',
properties: { a: { type: 'number' }, b: { type: 'number' } },
required: ['a', 'b']
}
};
console.log(validateCall(add, { a: 2, b: 3 }));
console.log(validateCall(add, { a: 2 }));
console.log(validateCall(add, { a: 'x', b: 3 }));
Optional arguments and defaults
Anything absent from required is optional, so the validator skips the presence check for it — but it still type-checks the value when the caller supplies one. A useful schema also marks a default on optional properties, so a missing limit can be filled in as 10 before dispatch instead of arriving as undefined. Filling defaults is a separate step from validation: validate first, merge defaults second, then hand the cleaned arguments to the function. Keeping those steps apart is what lets one definition feed the model, the validator, and the dispatcher without the three tangling — the model never sees the defaults, the validator never applies them, and the dispatcher can rely on them.
function applyDefaults(tool, args) {
const out = Object.assign({}, args);
const props = tool.inputSchema.properties || {};
for (const name of Object.keys(props)) {
if (!(name in out) && props[name] && 'default' in props[name]) {
out[name] = props[name].default;
}
}
return out;
}
const search = {
name: 'search',
description: 'Search the docs',
inputSchema: {
type: 'object',
properties: {
query: { type: 'string' },
limit: { type: 'number', default: 10 }
},
required: ['query']
}
};
console.log(applyDefaults(search, { query: 'cats' }).limit);
console.log(applyDefaults(search, { query: 'cats', limit: 3 }).limit);
flowchart TD C["model proposes a call"] --> CHECK["check each required arg:<br/>present and correctly typed"] CHECK -->|"all pass"| OK["accept and dispatch"] CHECK -->|"any fail"| BAD["reject before dispatch"] style OK fill:#166534,color:#fff style BAD fill:#b91c1c,color:#fff
What does this print? The signature is built from the required list, and the property type is read straight from the schema.
const tool = {
name: 'search',
inputSchema: {
type: 'object',
properties: { query: { type: 'string' }, limit: { type: 'number' } },
required: ['query']
}
};
function signature(t) { return t.name + '(' + t.inputSchema.required.join(', ') + ')'; }
console.log(signature(tool));
console.log(tool.inputSchema.properties.limit.type);
required is ['query'], so the signature is search(query).
limit's type is read from properties.limit.type.
Write requiredArgs(tool) that returns the tool's required array, or an empty array if the schema declares none.
function requiredArgs(tool) {
// return the required array, or [] if absent
}
Read tool.inputSchema.required, but guard for when it is missing.
Use || [] so a schema with no required list yields an empty array.
function requiredArgs(tool) {
return (tool.inputSchema && tool.inputSchema.required) || [];
}
Write validateCall(tool, args) that returns true only when every required argument is present (test with in) AND matches its declared type. The matchesType helper and the tool are already defined for you — focus on the loop.
function matchesType(value, type) {
if (type === 'array') return Array.isArray(value);
if (type === 'number') return typeof value === 'number';
if (type === 'string') return typeof value === 'string';
if (type === 'boolean') return typeof value === 'boolean';
return true;
}
const tool = {
name: 'create_user',
inputSchema: {
type: 'object',
properties: { email: { type: 'string' }, roles: { type: 'array' } },
required: ['email', 'roles']
}
};
function validateCall(tool, args) {
// every required arg must be present (use `in`) and match its type
}
Loop over schema.required; for each name, first check name in args.
Then check matchesType(args[name], props[name].type); return false on the first failure.
function matchesType(value, type) {
if (type === 'array') return Array.isArray(value);
if (type === 'number') return typeof value === 'number';
if (type === 'string') return typeof value === 'string';
if (type === 'boolean') return typeof value === 'boolean';
return true;
}
const tool = {
name: 'create_user',
inputSchema: {
type: 'object',
properties: { email: { type: 'string' }, roles: { type: 'array' } },
required: ['email', 'roles']
}
};
function validateCall(tool, args) {
const schema = tool.inputSchema;
const props = schema.properties || {};
for (const name of schema.required || []) {
if (!(name in args)) return false;
if (!matchesType(args[name], props[name] && props[name].type)) return false;
}
return true;
}
This validator should return true when every required argument is supplied. But it checks !args[name], which treats 0, '', and false as missing — so a valid call with count: 0 is wrongly rejected. Fix it to test whether the key EXISTS, not whether its value is truthy.
const tool = {
inputSchema: { properties: { count: { type: 'number' } }, required: ['count'] }
};
function validateCall(tool, args) {
for (const name of tool.inputSchema.required) {
if (!args[name]) return false; // BUG: rejects count: 0
}
return true;
}
The bug confuses a falsy value with a missing key.
Use the
inoperator to test that the key exists, regardless of its value.
const tool = {
inputSchema: { properties: { count: { type: 'number' } }, required: ['count'] }
};
function validateCall(tool, args) {
for (const name of tool.inputSchema.required) {
if (!(name in args)) return false;
}
return true;
}
Write applyDefaults(tool, args) that returns a NEW arguments object: any optional property that declares a default and is ABSENT from args gets filled in; an explicit value is always kept. The original args must not be mutated. The tool is already defined for you.
const tool = {
name: 'search',
inputSchema: {
type: 'object',
properties: { query: { type: 'string' }, limit: { type: 'number', default: 10 } },
required: ['query']
}
};
function applyDefaults(tool, args) {
// return a new object; fill in defaults for absent optional-with-default props
}
Copy args first with Object.assign({}, args) so the original is not mutated.
For each property with a default that is absent from the copy, set it.
const tool = {
name: 'search',
inputSchema: {
type: 'object',
properties: { query: { type: 'string' }, limit: { type: 'number', default: 10 } },
required: ['query']
}
};
function applyDefaults(tool, args) {
const out = Object.assign({}, args);
const props = tool.inputSchema.properties || {};
for (const name of Object.keys(props)) {
if (!(name in out) && props[name] && 'default' in props[name]) {
out[name] = props[name].default;
}
}
return out;
}
Recap
- A tool definition has three fields: a
name(the call identifier), adescription(the model's only cue for when to call), and aninputSchema(a JSON Schema the validator enforces). - One definition serves two audiences: the model reads the description, your validator reads the schema.
- Validate before dispatch: every required argument must be present, and every present argument must match its type.
- Test presence with
in, never with truthiness —0,'', andfalseare valid values that happen to be falsy. typeof []andtypeof nullare both'object', so checkarraywithArray.isArrayand never trusttypeofalone for objects.- Keep the steps apart: validate, then merge defaults, then dispatch.
Next you will put definitions to work inside the loop that actually runs them — reading a model's tool request, executing it, and feeding the result back.