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 — 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 planning 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:
| 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.
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), }, } },}