Skip to content

Tool Schema

Every tool on an MCP server has an input schema that defines what parameters it accepts, an optional output schema that defines what it returns, and a handler that answers each call with a result. Schemas are published as JSON Schema in the server’s tools/list response, and oHallo’s agents rely on them to decide what data to pass when they call your tools.

The examples on this page use version 2 of the official MCP TypeScript SDK, @modelcontextprotocol/server, set up as in Build an MCP Server: server is that guide’s McpServer, z is Zod (import * as z from 'zod'), and answer and toolError are its two helpers.

In tools/list an input schema is a JSON Schema object with "type": "object", and each property defines a parameter that the tool accepts. With the SDK you give registerTool a Zod object:

inputSchema: z.object({
orderNumber: z.string().describe('The order number to look up, e.g. ORD-48291'),
}),

The SDK publishes it as JSON Schema:

{
"type": "object",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"orderNumber": {
"type": "string",
"description": "The order number to look up, e.g. ORD-48291"
}
},
"required": [
"orderNumber"
]
}

oHallo adds tenantId and workspaceId to the arguments of every tool call, each the UUID of the oHallo account and of the workspace the conversation belongs to. The SDK passes a handler only the arguments its input schema declares, so a tool that uses them declares them as optional strings, as tenantId and workspaceId or under another spelling of the same name that differs only in letter case and separators, such as tenant_id or workspace-id. oHallo leaves every such input out of the schema the assistant sees, on every channel, and fills it on every call, replacing any value written in it.

Your server can use them for:

  • Data isolation: if you serve multiple oHallo accounts, filter queries by tenantId
  • Configuration lookup: load account-specific settings based on the tenantId or workspaceId
  • Audit logging: record which account triggered each operation
server.registerTool(
'get_order',
{
description: 'Look up an order by order number. Return only the data provided by this tool.',
inputSchema: z.object({
orderNumber: z.string().describe('The order number to look up, e.g. ORD-48291'),
tenantId: z.string().optional(), // filled by oHallo
workspaceId: z.string().optional(), // filled by oHallo
}),
},
async ({ orderNumber, tenantId }) => {
// Use tenantId to scope the query
const order = await db.query('SELECT * FROM orders WHERE tenant_id = $1 AND order_number = $2', [tenantId, orderNumber])
return answer(order)
},
)

If your server does not need account-level data isolation (e.g. it already serves a single customer), leave them out of the schema.

Define the parameters your tool needs. Use clear types and include a description for each:

inputSchema: z.object({
customerId: z.string().describe('The unique customer identifier, e.g. cust_12345'),
includeHistory: z.boolean().optional().describe('If true, include the last 10 orders in the response'),
maxResults: z.number().int().optional().describe('Maximum number of results to return (1-100, default 20)'),
}),

z.number().int() is published as "type": "integer", bounded to the integers JavaScript represents exactly.

Use enum when a parameter should be one of a fixed set of values:

status: z
.enum(['pending', 'confirmed', 'shipped', 'delivered', 'cancelled'])
.describe('Filter orders by status'),

Dates are represented as strings. Use the description to specify the expected format:

fromDate: z.string().describe('Start date for the search range in ISO 8601 format, e.g. 2026-01-15'),
toDate: z.string().describe('End date for the search range in ISO 8601 format, e.g. 2026-03-20'),
productIds: z.array(z.string()).describe('List of product IDs to check inventory for'),
shippingAddress: z
.object({
street: z.string().describe('Street address'),
city: z.string().describe('City name'),
postalCode: z.string().describe('Postal or ZIP code'),
country: z.string().describe('ISO 3166-1 alpha-2 country code, e.g. DE, NL, US'),
})
.describe('The delivery address for the shipment'),

A Zod field is required unless you mark it .optional(), and the SDK lists every required field in the schema’s required array. Optional parameters are omitted from required, and the agent includes them only when it has the information:

inputSchema: z.object({
query: z.string().describe('Search term to match against product name or description'),
category: z.string().optional().describe('Filter by product category, e.g. electronics, clothing'),
maxResults: z.number().int().optional().describe('Maximum number of results to return (default 20)'),
}),

In this example, category and maxResults are optional. The agent includes them when the customer mentions a category or asks for a specific number of results. This tool has no use for tenantId and workspaceId, so it leaves them out.

Tool descriptions are read twice. The Orchestrator reads each agent’s tools, by name and description, when it decides which agent takes a request, and the agent reads your tool and parameter descriptions to decide when and how to call each tool. Good descriptions directly affect how well the agent serves your customers.

Tool descriptions should explain what the tool does, what it returns, and when to use it:

WeakStrong
"Get order""Look up an order by order number. Returns the order status, line items with quantities and prices, shipping address, and estimated delivery date."
"Search""Search the product catalog by name, SKU, or description. Returns matching products with prices and stock availability. Use this when a customer asks about a product."

Parameter descriptions should include the expected format, an example value, and any constraints:

WeakStrong
"The ID""The customer's order number, e.g. ORD-48291"
"Date""Order date in ISO 8601 format, e.g. 2026-03-20"
"Status""Filter by order status. One of: pending, confirmed, shipped, delivered, cancelled"

A tool handler returns a CallToolResult. Return the data as an object with clear field names, as structuredContent and as the same JSON in the result’s one text item, as answer does:

async ({ orderNumber }) => {
const order = await db.getOrder(orderNumber)
return answer({
orderNumber: order.number,
status: order.status,
placedAt: order.createdAt.toISOString(),
items: order.lines.map((line) => ({
name: line.productName,
quantity: line.quantity,
unitPrice: line.unitPrice,
currency: line.currency,
})),
total: {
amount: order.totalAmount,
currency: order.currency,
},
shipping: {
carrier: order.shippingCarrier,
trackingNumber: order.trackingNumber,
estimatedDelivery: order.estimatedDelivery?.toISOString() ?? null,
},
})
}

The agent that called the tool reads the result, less any fields a mask guardrail removes. oHallo also carries the tool’s data itself, as the agent received it, to the steps that decide and write the reply, so the reply works from your data and not only from the agent’s account of it. The carried data is structuredContent when the result has it, and otherwise the JSON in the result’s one text item; a JSON array is carried as an object whose items field holds the array. Each answer is carried up to 64 KB, and all the answers one agent’s tools give in one run up to 128 KB together. A larger answer is left out whole, and the reply is then written from the agent’s account of it.

Guidelines for return values:

  • Use clear field names. The agent reads the returned data to compose its response. estimatedDelivery is better than eta or del_dt.
  • Include units and currency. Do not return bare numbers. { amount: 149.99, currency: 'EUR' } is better than { total: 149.99 }.
  • Return null for missing optional fields. The agent can tell the customer “no tracking information available yet” rather than seeing an undefined field.
  • Keep responses focused. Return the data relevant to the tool’s purpose. A get_order tool does not need to return the entire customer profile.
  • Return one object. Put the whole answer in one JSON object, in one text item and in structuredContent.

Everything oHallo knows about your tools comes from your tools/list response, and that response does more than drive dispatch. Before an agent can use a tool, it needs a risk classification and a declaration of whose records it touches (its scope), which a tenant admin reviews and sets; an agent never calls a tool without both. The more your tool declares up front, the less the admin has to reconstruct by hand.

Four things make a tool legible.

A plain-English description in the standard format

Section titled “A plain-English description in the standard format”

Agents read tool descriptions to decide which tool to call: the description is the primary dispatch signal. The Writing good descriptions section above covers the content. For the shape, follow this format:

[Imperative verb] [object] [scope or constraint]. [Behavioural note if needed.] Return only the data provided by this tool.
  • "Retrieve a single order by ID including line items, delivery status, and carrier tracking reference. Return only the data provided by this tool."
  • "Search orders for an account. Filters by status and date range. Returns the 20 most recent matching orders. Return only the data provided by this tool."
  • "Check whether line items from an order are eligible for return. Apply the 30-day window and condition rules from the response, and do not infer eligibility beyond what is returned."

The closing constraint is deliberate. Embedded in the description, the instruction travels with the tool to every agent that sees it, instead of relying on a system prompt your tool never controls. Where a tool needs a more specific behavioural constraint, as in the third example, state that constraint instead.

An input schema on every tool, with required fields explicit

Section titled “An input schema on every tool, with required fields explicit”

The rest of this page covers input schemas for dispatch; there is a governance reason too. The account admin reads your input schema when reviewing the tool, so a parameter without a description is a parameter they cannot evaluate. List every mandatory parameter as required, and make sure anything optional is genuinely optional in your handler. The Customer records settings list the top-level properties of your input schema when an admin chooses the input that names a record or the one that receives the customer’s number.

An output schema, even though the specification makes it optional

Section titled “An output schema, even though the specification makes it optional”

Since the 2025-06-18 revision, the MCP specification supports structured tool output: a tool may declare an outputSchema, a JSON Schema describing the object it returns in the result’s structuredContent field.

{
"name": "get_order",
"description": "Retrieve a single order by order number including status, line items, and estimated delivery date. Return only the data provided by this tool.",
"inputSchema": {
"type": "object",
"properties": {
"orderNumber": {
"type": "string",
"description": "The order number to look up, e.g. ORD-48291"
}
},
"required": ["orderNumber"]
},
"outputSchema": {
"type": "object",
"properties": {
"orderNumber": { "type": "string" },
"status": {
"type": "string",
"enum": ["processing", "confirmed", "shipped", "delivered", "cancelled"]
},
"items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": { "type": "string" },
"quantity": { "type": "integer" },
"unitPrice": { "type": "number" },
"currency": { "type": "string", "description": "ISO 4217, e.g. EUR" }
},
"required": ["name", "quantity", "unitPrice", "currency"]
}
},
"estimatedDelivery": {
"type": ["string", "null"],
"description": "ISO 8601 date, null until delivery is scheduled"
}
},
"required": ["orderNumber", "status", "items"]
}
}

Declaring one is a contract. The specification requires a server that declares an outputSchema to return structured results that conform to it, and asks clients to validate them. For backwards compatibility, the specification asks a tool that returns structured content to return the same object serialised in a text content block as well. With the SDK, declare the schema as outputSchema in registerTool, in Zod like the input schema, and return the object both as structuredContent and as JSON in a text item, as answer does. The SDK checks structuredContent against the schema before it answers, and answers a result whose structuredContent is missing or does not match as an error whose text begins Output validation error.

oHallo shows declared output schemas to account admins, so they can see what data each tool returns before wiring it to an agent: select a tool’s name in the connection’s Tools table to show the fields it declares. The same fields fill the pickers that use them: the identity verification designations, and, on the Customer records settings, Field that says whose data it is and Field that holds what this tool creates. For a tool without an output schema, the admin types each field path by hand.

A list of returned fields written into the tool’s description reads well to an admin, but nothing checks it against what the tool returns. outputSchema is the form of that promise that is checked; if you keep one of the two, keep the outputSchema.

The specification has defined tool annotations since the 2025-03-26 revision: optional hints describing how a tool behaves.

AnnotationQuestion it answersSpecification default if omitted
readOnlyHintDoes the tool leave its environment unmodified?false
destructiveHintMay the tool perform destructive updates, as opposed to only additive ones? Meaningful only when readOnlyHint is false.true
idempotentHintDoes repeating the call with the same arguments have no additional effect? Meaningful only when readOnlyHint is false.false
openWorldHintDoes the tool interact with an open world of external entities (a web search does; a lookup in your own database does not)?true

oHallo stores the annotations it discovers and gives each newly discovered tool its first risk classification from them: a tool marked destructiveHint: true starts as destructive, a tool marked readOnlyHint: true starts as read (PII) (read_pii in the API), and any other tool starts as write (PII) (write_pii). The classification applies until an administrator changes it, so accurate annotations mean the admin starts from a sensible classification instead of classifying your catalogue from scratch.

{
"name": "cancel_subscription",
"description": "Cancel an active subscription at the end of the current billing period. Return only the data provided by this tool.",
"inputSchema": {
"type": "object",
"properties": {
"subscriptionId": {
"type": "string",
"description": "The subscription to cancel, e.g. SUB-2041"
}
},
"required": ["subscriptionId"]
},
"annotations": {
"readOnlyHint": false,
"destructiveHint": true,
"idempotentHint": true,
"openWorldHint": false
}
}

Cancelling is financially material (destructiveHint: true), cancelling twice changes nothing further (idempotentHint: true), and the tool touches only your own billing system (openWorldHint: false). oHallo classifies this tool destructive when it discovers it.

Annotations are hints, and the specification asks clients not to base decisions on annotations from servers they do not trust. In oHallo they set the first classification; the stored classification is what the assurance gate enforces, and an administrator can change it. Whether a read tool is read or read_pii is set by an administrator, because annotations do not describe personal data.

With the SDK, a tool declares them in the annotations field of its registerTool configuration.

A tool declared at the customer level, or at the company level, on the Customer records page can take the customer’s number, or the company’s account number, in an input you choose under Input that receives the customer number (or Input that receives the company account number). Declare that input as a top-level property of the input schema. oHallo leaves it out of the schema the assistant sees on every channel, as it does tenantId and workspaceId, and on every call fills it with the number of the customer who verified, or of their company, replacing anything written in it. A tool at either level that changes records names that input too.

server.registerTool(
'search_orders',
{
description: "List the customer's orders, most recent first. Return only the data provided by this tool.",
inputSchema: z.object({
customer_id: z.string().describe('The customer whose orders to list'),
status: z.string().optional().describe('Filter by order status'),
}),
},
async ({ customer_id, status }) => {
const orders = await db.findOrders(customer_id, status)
return answer({ orders, total_count: orders.length })
},
)
  • Every item names the owner. Each item of the list carries the customer’s number, or at the company level the company’s account number, at the field you declare as the one that names the owner, picked on the dashboard as orders[0].customer_id and sent over the API as "ownerField": "orders[].customer_id", which oHallo checks on every item. oHallo withholds the whole answer when one item names someone else.
  • An empty list for none. When the customer has no records, answer an empty list, such as { "orders": [], "total_count": 0 }, and the assistant says there are none. An error such as not_found holds no list, so oHallo withholds it from the assistant.
  • Answer in JSON. oHallo reads the owner from the JSON of the answer, in its text items and in structuredContent. An answer that holds anything else, such as an image or text that is not JSON, names no owner and is withheld.
  • Other references in the answer. On these tools, a field named account_id, customer_id, client_id, owner_id or buyer_id, in snake case or camel case, that holds a number other than the verified customer’s, or their company’s, withholds the answer, unless an admin lists the field under Fields that name someone else.
  • Describe the tool by what it returns. Write the description as what the tool lists, for example “List the customer’s orders, most recent first. Return only the data provided by this tool.” The description never asks for the customer’s number, which oHallo fills.
  • A tool that reads or changes one record names the inputs that name it, such as order_id, under Inputs that say what is asked for. When the customer is asked to verify, oHallo can show that value as what they asked for.

A tool declared A customer or A customer’s company needs a customer lookup on the same connection that returns your customer number beside the CPR number or the MitID UUID: set it up under The customer’s CPR number or The customer’s MitID UUID, in Verifying with MitID on the connection’s Customer records page, as the customer lookup contract describes. A customer reaches these tools after verifying with National eID. A tool declared A customer that reads records, classified read or read_pii, and requires identified or anonymous also opens for a customer tied to their own record by the email link, the email sender address or Caller ID, and for it a lookup set up under By email address with its field with the email address, or under By phone number with its field with the phone number, in Finding customers, serves in place of the eID lookup. The Identity and Verification overview describes where each method runs.

A tool answers an error with a result marked isError whose text item is the JSON of an object holding an error_type and a reason, as toolError builds it. The agent that called the tool reads that text, so write a reason that tells it what happened. Name the type after what happened in your system, such as order_not_eligible.

On a tool declared A customer or A customer’s company on the Customer records page, an error about the caller’s own record also names the record’s owner, at the field you declare as the one that names the owner, as every answer of the tool does (the customer lookup contract). The assistant reads it like any other answer. A record of another customer is answered exactly as one that does not exist, with not_found.

This cancel_order tool is declared A customer, with customer_id as the input that receives the customer’s number and as the field that names the owner:

server.registerTool(
'cancel_order',
{
description: 'Cancel an order that has not been delivered. Return only the data provided by this tool.',
inputSchema: z.object({
order_id: z.string().describe('The order to cancel, e.g. ORD-001'),
customer_id: z.string().describe('The customer the order must belong to'),
}),
},
async ({ order_id, customer_id }) => {
if (!/^ORD-\d+$/.test(order_id)) {
return toolError({ error_type: 'validation_error', reason: 'Order number must have the form ORD-001' })
}
const order = await db.getOrder(order_id)
// Another customer's order is answered exactly as one that does not exist.
if (!order || order.customerId !== customer_id) {
return toolError({ error_type: 'not_found', reason: `Order ${order_id} not found` })
}
if (order.status === 'delivered') {
return toolError({
error_type: 'order_not_eligible',
reason: `Order ${order.id} cannot be cancelled: it is already delivered`,
order_id: order.id,
customer_id: order.customerId,
account_id: order.accountId,
})
}
await db.cancelOrder(order.id)
return answer({ order_id: order.id, status: 'cancelled', customer_id: order.customerId, account_id: order.accountId })
},
)

An error that names no owner, such as one about the input, authentication, a rate limit or your server, is withheld from the assistant on a tool declared A customer or A customer’s company, like any answer that names no owner; on a tool declared at the record level, or as returning no customer records, the agent reads it. On a tool whose owner field sits in a list, such as orders[0].customer_id, an error holds no list and is withheld. Return errors as results: an error a handler throws reaches oHallo as a result marked isError whose text item holds only the error’s message, with no error_type and no owner, and the SDK answers arguments that do not match the input schema the same way.

Choose the error type that best matches the situation:

Error typeUse when
not_foundThe requested record does not exist, or belongs to another customer
validation_errorInput parameters are malformed or out of range
authentication_errorThe request could not be authenticated
rate_limitToo many requests in a short period
server_errorAn unexpected failure in your system

Complete example: search tool with filters

Section titled “Complete example: search tool with filters”

Here is a complete tool definition for a product search with multiple filter options:

server.registerTool(
'search_products',
{
description:
'Search the product catalog by name or description. Supports filtering by category, price range, and availability. Returns matching products with current prices and stock status.',
inputSchema: z.object({
query: z.string().describe('Search term to match against product name and description'),
category: z
.enum(['electronics', 'clothing', 'home', 'sports', 'books'])
.optional()
.describe('Filter results to a specific product category'),
minPrice: z.number().optional().describe('Minimum price in EUR, e.g. 10.00'),
maxPrice: z.number().optional().describe('Maximum price in EUR, e.g. 500.00'),
inStockOnly: z.boolean().optional().describe('If true, only return products that are currently in stock'),
page: z.number().int().optional().describe('Page number for pagination (starts at 1, default 1)'),
pageSize: z.number().int().optional().describe('Number of results per page (1-50, default 20)'),
}),
annotations: { readOnlyHint: true, openWorldHint: false },
},
async (args) => {
const page = args.page ?? 1
const pageSize = Math.min(args.pageSize ?? 20, 50)
const results = await productSearch({
query: args.query,
category: args.category,
minPrice: args.minPrice,
maxPrice: args.maxPrice,
inStockOnly: args.inStockOnly ?? false,
offset: (page - 1) * pageSize,
limit: pageSize,
})
return answer({
products: results.items.map((p) => ({
id: p.id,
name: p.name,
description: p.shortDescription,
category: p.category,
price: { amount: p.price, currency: 'EUR' },
inStock: p.stockQuantity > 0,
stockQuantity: p.stockQuantity,
})),
pagination: {
page,
pageSize,
totalResults: results.total,
totalPages: Math.ceil(results.total / pageSize),
},
})
},
)