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.
Input schema format
Section titled “Input schema format”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'],}Injected fields: tenantId and workspaceId
Section titled “Injected fields: tenantId and workspaceId”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
tenantIdorworkspaceId - 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.
Defining your fields
Section titled “Defining your fields”Define the parameters your tool needs. Use clear types and include a description for each:
Basic types
Section titled “Basic types”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)', },}Enum fields
Section titled “Enum fields”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',}Date fields
Section titled “Date fields”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',}Arrays
Section titled “Arrays”productIds: { type: 'array', items: { type: 'string' }, description: 'List of product IDs to check inventory for',}Nested objects
Section titled “Nested objects”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',}Required vs. optional fields
Section titled “Required vs. optional fields”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.
Writing good descriptions
Section titled “Writing good descriptions”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:
| Weak | Strong |
|---|---|
"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:
| Weak | Strong |
|---|---|
"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" |
Return values
Section titled “Return values”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.
estimatedDeliveryis better thanetaordel_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_ordertool does not need to return the entire customer profile.
Make your tools legible to oHallo
Section titled “Make your tools legible to oHallo”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.
Accurate annotations
Section titled “Accurate annotations”The spec has defined tool annotations since the 2025-03-26 revision: optional hints describing how a tool behaves.
| Annotation | Question it answers | Spec default if omitted |
|---|---|---|
readOnlyHint | Does the tool leave its environment unmodified? | false |
destructiveHint | May the tool perform destructive updates, as opposed to only additive ones? Meaningful only when readOnlyHint is false. | true |
idempotentHint | Does repeating the call with the same arguments have no additional effect? Meaningful only when readOnlyHint is false. | false |
openWorldHint | Does 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.
Error handling
Section titled “Error handling”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 type | Use when | Agent behaviour |
|---|---|---|
not_found | The requested resource does not exist | Tells the customer the item was not found |
validation_error | Input parameters are malformed or out of range | Asks the customer to clarify or correct their input |
authentication_error | The request could not be authenticated | Reports a system issue (not shown as customer’s fault) |
permission_denied | The caller lacks access to this resource | Tells the customer they do not have access |
rate_limit | Too many requests in a short period | May retry or inform the customer of a temporary delay |
server_error | An unexpected failure in your system | Reports 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), }, } },}