Skip to content

Tool Schema

Every tool on an MCP server has an input schema that defines what parameters it accepts, and a handler that returns structured data. The schema uses JSON Schema format, and oHallo’s agents rely on it to determine what data to pass when calling your tools.

An input schema is a JSON Schema object with type: 'object'. Each property defines a parameter that the tool accepts:

{
type: 'object',
properties: {
orderNumber: {
type: 'string',
description: 'The order number to look up, e.g. ORD-48291',
},
},
required: ['orderNumber'],
}

MCP Hub automatically injects tenantId and workspaceId into every tool call’s arguments at call time. You do not need to declare them in your input schema. They arrive as extra fields in the args object your handler receives.

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
handler: async (args) => {
const tenantId = args.tenantId as string // injected by MCP Hub
const workspaceId = args.workspaceId as string // injected by MCP Hub
const orderNumber = args.orderNumber as string
// 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 order
}

If your server does not need account-level data isolation (e.g. it already serves a single customer), you can simply ignore these fields.

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

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

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

status: {
type: 'string',
enum: ['pending', 'confirmed', 'shipped', 'delivered', 'cancelled'],
description: 'Filter orders by status',
}

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

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

List all mandatory parameters in the required array. Optional parameters are omitted from required, and the agent will only include them when it has the information:

{
type: 'object',
properties: {
query: {
type: 'string',
description: 'Search term to match against product name or description',
},
category: {
type: 'string',
description: 'Filter by product category, e.g. electronics, clothing',
},
maxResults: {
type: 'integer',
description: 'Maximum number of results to return (default 20)',
},
},
required: ['query'],
}

In this example, category and maxResults are optional. The agent will include them when the customer mentions a category or asks for a specific number of results. Note that tenantId and workspaceId are not listed here. They are injected automatically by MCP Hub.

The orchestrator 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"

Tool handlers return plain JavaScript objects. The MCP framework serialises them to JSON automatically. Return structured data with clear field names:

handler: async (args) => {
const order = await db.getOrder(args.orderNumber as string)
return {
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,
},
}
}

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.

Everything oHallo knows about your tools comes from your tools/list response, and that response does more than drive dispatch. Before a tool can be used at all, a tenant admin reviews it, decides what data it may touch, and gives it a risk classification — an unclassified tool is never callable. The more your tool declares up front, the less the admin has to reconstruct by hand, and the better oHallo can display and govern the tool on your behalf.

Five 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 — do not infer eligibility beyond what is returned."

The closing constraint is deliberate. Embedded in the description, the anti-hallucination 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 inputSchema on every tool, with required fields explicit

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

The rest of this page covers input schemas for dispatch; there is a governance reason too. The tenant admin reads your input schema when reviewing the tool, so a parameter without a description is a parameter they cannot evaluate. Write schemas with JSON Schema draft-07 keywords, list every mandatory parameter in the required array, and make sure anything you leave out of required is genuinely optional in your handler.

An outputSchema, even though the spec makes it optional

Section titled “An outputSchema, even though the spec 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 when not yet scheduled"
}
},
"required": ["orderNumber", "status", "items"]
}
}

Declaring one is a contract, not a comment. The spec requires a server that declares an outputSchema to return structured results that conform to it, and expects clients to validate them. For backwards compatibility the same object should also be serialised into a text content block — @standfast/mcp-server does both automatically when a tool declares outputSchema, so your handler keeps returning a plain object.

oHallo surfaces declared output schemas to tenant admins, so they can see what data each tool returns before wiring it to an agent. A tool without one is a black box at review time: the admin can read your description and hope, or call the tool and inspect. Declared outputs also unlock platform features directly — the identity verification designation surface derives its pickable fields from your declared outputSchema.

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

AnnotationQuestion it answersSpec 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 uses them to seed each tool’s risk classification — the classification every tool must have before it is callable. Accurate annotations mean the tenant admin confirms a sensible suggestion instead of classifying your catalogue from scratch. Note that the spec defaults are pessimistic: an unannotated state-changing tool reads as “possibly destructive”. If your tool is read-only or merely additive, say so.

{
"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).

Two honest caveats. Annotations are hints: the spec requires clients to treat them as untrusted, so in oHallo they seed the classification but never decide it — the stored classification is what the assurance gate enforces, and the admin can always override. And annotations cannot see personal data: whether a read tool is read or read_pii remains a human judgement.

HubSpot’s MCP server is the example to copy here. Every tool it exposes ships an inputSchema and annotations (readOnlyHint, destructiveHint, idempotentHint), which is exactly what lets a platform propose classifications for a whole catalogue instead of asking an admin to research each tool by hand.

The @standfast/mcp-server package does not currently accept annotations on a tool definition. If you build with an official MCP SDK, or emit the tools/list response yourself, include them there.

<returns> blocks are readable; outputSchema is checkable

Section titled “<returns> blocks are readable; outputSchema is checkable”

Some vendors — HubSpot again — embed a structured <returns> block inside the tool description, listing the fields the tool returns. oHallo renders descriptions to admins, so a block like that is genuinely useful to a human reader. But nothing checks it: a <returns> block can drift from what the tool actually returns, and no client will ever notice. outputSchema is the machine-checkable form of the same promise — conformance is required by the spec and validated by clients. If you maintain both, fine; if you maintain one, make it the outputSchema.

Use McpError to signal errors. The agent reads the error type and message to explain the situation to the customer:

import { McpError } from '@standfast/mcp-server'
handler: async (args) => {
const orderId = args.orderNumber as string
if (!orderId.match(/^ORD-\d+$/)) {
throw new McpError('validation_error', 'Order number must match format ORD-XXXXX')
}
const order = await db.getOrder(orderId)
if (!order) {
throw new McpError('not_found', `Order ${orderId} does not exist`)
}
if (order.tenantId !== args.tenantId) {
throw new McpError('permission_denied', 'You do not have access to this order')
}
return { orderNumber: order.number, status: order.status }
}

Choose the error type that best matches the situation:

Error typeUse whenAgent behaviour
not_foundThe requested resource does not existTells the customer the item was not found
validation_errorInput parameters are malformed or out of rangeAsks the customer to clarify or correct their input
authentication_errorThe request could not be authenticatedReports a system issue (not shown as customer’s fault)
permission_deniedThe caller lacks access to this resourceTells the customer they do not have access
rate_limitToo many requests in a short periodMay retry or inform the customer of a temporary delay
server_errorAn unexpected failure in your systemReports a temporary issue and suggests trying again

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:

{
name: '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: {
type: 'object',
properties: {
query: {
type: 'string',
description: 'Search term to match against product name and description',
},
category: {
type: 'string',
enum: ['electronics', 'clothing', 'home', 'sports', 'books'],
description: 'Filter results to a specific product category',
},
minPrice: {
type: 'number',
description: 'Minimum price in EUR, e.g. 10.00',
},
maxPrice: {
type: 'number',
description: 'Maximum price in EUR, e.g. 500.00',
},
inStockOnly: {
type: 'boolean',
description: 'If true, only return products that are currently in stock',
},
page: {
type: 'integer',
description: 'Page number for pagination (starts at 1, default 1)',
},
pageSize: {
type: 'integer',
description: 'Number of results per page (1-50, default 20)',
},
},
required: ['query'],
},
handler: async (args) => {
const query = args.query as string
const category = args.category as string | undefined
const minPrice = args.minPrice as number | undefined
const maxPrice = args.maxPrice as number | undefined
const inStockOnly = (args.inStockOnly as boolean) ?? false
const page = (args.page as number) ?? 1
const pageSize = Math.min((args.pageSize as number) ?? 20, 50)
const results = await productSearch({
query,
category,
minPrice,
maxPrice,
inStockOnly,
offset: (page - 1) * pageSize,
limit: pageSize,
})
return {
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),
},
}
},
}