Learn best practices for designing and implementing WebMCP tools on websites you control. Tool design principles, naming conventions, security, and optimal AI integration.
This guide covers the complete tool development lifecycle: from naming and schema design, through implementation and error handling, to testing and monitoring. Unlike userscripts where you work around existing structures, controlling your entire website lets you design tools from the ground up for optimal AI integration.
These practices apply when you own the website and can integrate WebMCP tools directly into your codebase. For userscript development, see Managing Userscripts.
Tool names, descriptions, and input schemas are sent directly to the AI model’s context. This is the ONLY information the model has about your tools. Make these as detailed and informative as possible.
Help AI agents understand when and how to use your tools by including everything they need to know in the description:
Copy
navigator.modelContext.registerTool({ name: 'products_search', description: 'Searches for products', // Too vague! // Model doesn't know when to use this or what it returns}); navigator.modelContext.registerTool({ name: 'products_search', description: `Search products by name, category, or SKU. // [!code ++]Returns paginated results with title, price, stock status, and product URLs. // [!code ++]Use this when users ask about product availability or pricing. // [!code ++]If searching by price range, use the minPrice and maxPrice parameters. // [!code ++]Results are sorted by relevance by default.`, inputSchema: { query: z.string().min(1).describe('Product name, category, or SKU to search for'), minPrice: z.number().positive().optional().describe('Minimum price filter in USD'), maxPrice: z.number().positive().optional().describe('Maximum price filter in USD'), limit: z.number().int().min(1).max(100).default(10).describe('Number of results (max 100)') } // ...});
Create consolidated tools that handle related operations rather than many single-purpose tools:
Good: Consolidated Tools
Avoid: Too Many Single-Purpose Tools
Copy
// ✅ Powerful tool handling related operationsnavigator.modelContext.registerTool({ name: 'cart_manager', description: 'Manage all cart operations including add, remove, view, clear, and update quantity. Use action parameter to specify the operation.', inputSchema: { action: z.enum(['add', 'remove', 'view', 'clear', 'update']).describe('The cart operation to perform'), productId: z.string().optional().describe('Product ID (required for add, remove, update)'), quantity: z.number().positive().optional().describe('Quantity (required for add and update)') }, // ...});
Parameter descriptions (via .describe()) are sent to the AI model’s context. Be detailed and specific!
Zod provides excellent TypeScript integration and runtime validation. Use .describe() on every parameter to tell the model exactly what it needs to know:
Copy
import { z } from 'zod';navigator.modelContext.registerTool({ name: 'products_search', description: 'Search products by various criteria', inputSchema: { query: z.string() .min(1, 'Search query cannot be empty') .max(100, 'Search query too long') .describe('Product name, category, or SKU to search for. Examples: "laptop", "running shoes", "SKU-12345"'), minPrice: z.number() .positive() .optional() .describe('Minimum price filter in USD. Use with maxPrice to filter by price range.'), maxPrice: z.number() .positive() .optional() .describe('Maximum price filter in USD. Use with minPrice to filter by price range.'), category: z.enum(['electronics', 'clothing', 'home', 'sports']) .optional() .describe('Product category filter. Choose from: electronics, clothing, home, or sports.'), limit: z.number() .int() .min(1) .max(100) .default(10) .describe('Number of results to return (1-100, default: 10). Use higher values for broader searches.') }, async execute({ query, minPrice, maxPrice, category, limit }) { // TypeScript knows the exact types here // ... }});
AI models work better with markdown-formatted responses than JSON. Markdown is more readable and easier for models to incorporate into natural language responses.
Return data as markdown strings rather than JSON objects:
Tools run with the user’s session, but always verify:
Copy
async execute({ orderId }) { const session = await getSession(); if (!session?.user) { return formatError( 'UNAUTHORIZED', 'Please log in to view order details.' ); } const order = await getOrder(orderId); // Verify order belongs to current user if (order.userId !== session.user.id && !session.user.isAdmin) { return formatError( 'FORBIDDEN', 'You do not have permission to view this order.' ); } return { content: [{ type: "text", text: JSON.stringify(order) }] };}
Voice models and real-time interactions work best with instant tool responses. Implement optimistic updates by operating on in-app state rather than waiting for async API calls:
Remember: Tool descriptions go into the model’s context. Reference other tools by name to help the model understand workflows and dependencies.
Make it clear when tools should be called in sequence or when one tool depends on another:
Copy
// ✅ References other tools and explains workflownavigator.modelContext.registerTool({ name: 'cart_checkout', description: `Proceed to checkout with current cart contents.Prerequisites:- User must be authenticated- Cart must contain at least one item (call cart_get_contents to verify)- Shipping address must be set (call user_set_shipping_address if needed)Returns a checkout URL where user can complete payment.`, // ...});// ✅ Mentions tool list changesnavigator.modelContext.registerTool({ name: 'user_login', description: `Authenticate user with email and password.After successful login, additional tools will become available:- orders_list_recent- user_get_profile- user_update_settingsCall this tool first if user wants to access account features.`, // ...});// ✅ References prerequisite toolnavigator.modelContext.registerTool({ name: 'order_track_shipment', description: `Get real-time tracking information for an order shipment.Prerequisite: Call orders_get_details first to get the tracking number.Requires the trackingNumber from that response.`, inputSchema: { trackingNumber: z.string().describe('Tracking number from orders_get_details') } // ...});
When to reference other tools:
A tool should be called before this one
This tool’s execution will register/unregister other tools
JSDoc comments and code documentation do NOT go into the model’s context. Only the tool name, description, and input schema are sent to the AI.
Put all important information in the tool description and parameter descriptions:
Copy
// ❌ JSDoc won't help the model/** * @param query - Search query * @param maxPrice - Maximum price */navigator.modelContext.registerTool({ name: 'products_search', description: 'Search products', // ...});// ✅ Everything in the description and schemanavigator.modelContext.registerTool({ name: 'products_search', description: `Search the product catalog using full-text search across names, descriptions, and SKUs.Returns paginated array of products with name, price, stock status, and URLs.Useful when users ask "what products do you have" or "find me a laptop under $1000".`, inputSchema: { query: z.string() .min(1) .max(100) .describe('Search query (1-100 characters). Can be product name, category, or SKU.'), category: z.enum(['electronics', 'clothing', 'home']) .optional() .describe('Optional category filter'), maxPrice: z.number() .positive() .optional() .describe('Optional maximum price filter in USD'), limit: z.number() .int() .min(1) .max(100) .default(10) .describe('Results per page (1-100, default: 10)') }});
// Option 1: Include version in name for breaking changesnavigator.modelContext.registerTool({ name: 'products_search_v2', description: 'Search products (v2 - new schema)', // ...});// Option 2: Include version in response metadataasync execute(params) { const results = await performSearch(params); return { content: [{ type: "text", text: `# Search Results (API v2.0.0)${results.map(r => `- ${r.title}`).join('\n')}---*Using API version 2.0.0*` }] };}
navigator.modelContext.registerTool({ name: 'legacy_search', description: '⚠️ DEPRECATED: Use products_search instead. This tool will be removed in v3.0.', async execute(params) { console.warn('legacy_search is deprecated, use products_search'); const results = await legacySearch(params); return { content: [{ type: "text", text: `⚠️ **DEPRECATION WARNING**This tool is deprecated and will be removed in v3.0.Please use \`products_search\` instead.---# Search Results${results.map(r => `- ${r.title}`).join('\n')}` }] }; }});