Skip to main content

Overview

Follow these best practices to build robust, performant, and user-friendly AI integrations with WebMCP.

Performance Optimization

Frontend tools should execute quickly to maintain responsive UI:
// ✅ Good - fast operation
useWebMCP({
  name: 'update_cart',
  description: 'Update cart',
  inputSchema: { /* ... */ },
  handler: async (input) => {
    updateCartUI(input);
    return { success: true };
  }
});

// ❌ Avoid - slow operation that blocks UI
useWebMCP({
  name: 'slow_operation',
  description: 'Slow operation',
  inputSchema: { /* ... */ },
  handler: async (input) => {
    await longRunningTask(); // Blocks UI thread
    return { done: true };
  }
});

// ✅ Better - delegate heavy work
useWebMCP({
  name: 'process_data',
  description: 'Process data',
  inputSchema: { /* ... */ },
  handler: async (input) => {
    // Queue heavy work, return immediately
    queueBackgroundJob(input);
    return { queued: true, jobId: '...' };
  }
});
Only recreate agents when tools actually change:
import { useMemo } from 'react';

function MyAssistant() {
  const { client, tools, isConnected } = useMcpClient();

  // ✅ Good - only recreates when dependencies change
  const agent = useMemo(() => {
    if (!isConnected || tools.length === 0) return null;
    return createAgent({ tools: convertTools(tools) });
  }, [tools, isConnected]);

  // ❌ Bad - recreates on every render
  const agent = createAgent({ tools: convertTools(tools) });
}
Avoid unnecessary re-renders when tool list updates:
// ✅ Good - only access what you need
function ToolCounter() {
  const { tools } = useMcpClient();
  return <span>{tools.length} tools</span>;
}

// ❌ Inefficient - causes re-render on any client state change
function ToolCounter() {
  const mcpClient = useMcpClient();
  return <span>{mcpClient.tools.length} tools</span>;
}

Error Handling

Use the onError callback to handle errors:
useWebMCP({
  name: 'delete_item',
  description: 'Delete an item',
  inputSchema: {
    itemId: z.string()
  },
  handler: async (input) => {
    // Errors thrown here are automatically caught
    const result = await performAction(input.itemId);
    return { success: true, itemId: input.itemId };
  },
  onError: (error, input) => {
    // Log or handle the error
    console.error('Tool failed:', error.message, 'for input:', input);

    // Optionally show UI notification
    showToast(`Failed to delete item: ${error.message}`);
  }
});
Always check connection status before using tools:
function MyFeature() {
  const { client, isConnected, error } = useMcpClient();

  // ✅ Good - handle all states
  if (error) {
    return <ErrorMessage error={error} />;
  }

  if (!isConnected) {
    return <ConnectingSpinner />;
  }

  return <YourFeature />;
}
Show loading and error states:
function ActionButton() {
  const { client, isConnected } = useMcpClient();
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  const handleAction = async () => {
    setLoading(true);
    setError(null);

    try {
      await client.callTool({
        name: 'my_action',
        arguments: {}
      });
    } catch (err) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  };

  return (
    <div>
      <button onClick={handleAction} disabled={!isConnected || loading}>
        {loading ? 'Processing...' : 'Execute Action'}
      </button>
      {error && <ErrorMessage>{error}</ErrorMessage>}
    </div>
  );
}

Input Validation

Use Zod schema validation in your inputSchema:
import { z } from 'zod';

useWebMCP({
  name: 'add_to_cart',
  description: 'Add product to cart',
  inputSchema: {
    productId: z.string()
      .regex(/^[A-Z0-9]+$/, 'Invalid product ID format')
      .min(3, 'Product ID too short'),
    quantity: z.number()
      .min(1, 'Quantity must be at least 1')
      .max(99, 'Quantity cannot exceed 99'),
    priority: z.enum(['low', 'medium', 'high']).optional()
  },
  handler: async (input) => {
    // input is fully typed and validated
    return { success: true };
  }
});
Make validation errors helpful:
useWebMCP({
  name: 'update_profile',
  description: 'Update user profile',
  inputSchema: {
    email: z.string()
      .email('Must be a valid email address')
      .max(255, 'Email too long'),
    age: z.number()
      .int('Age must be a whole number')
      .min(13, 'Must be at least 13 years old')
      .max(120, 'Age seems unrealistic')
  },
  handler: async (input) => {
    // Handler only runs if validation passes
    return await updateProfile(input);
  }
});

Output Formatting

The useWebMCP hook automatically formats your return value:
useWebMCP({
  name: 'get_cart',
  description: 'Get shopping cart',
  inputSchema: {},
  handler: async () => {
    const cart = await getCart();

    // Return structured data - automatically formatted
    return {
      items: cart.items,
      total: cart.total,
      itemCount: cart.items.length
    };
  },
  // Custom formatter for text representation
  formatOutput: (output) => {
    return `Cart has ${output.itemCount} items (total: $${output.total})`;
  }
});
Help the AI understand results better:
useWebMCP({
  name: 'search_products',
  description: 'Search products',
  inputSchema: {
    query: z.string()
  },
  handler: async (input) => {
    const results = await search(input.query);
    return { results, count: results.length };
  },
  formatOutput: (output) => {
    if (output.count === 0) {
      return 'No products found matching your search.';
    }

    return `Found ${output.count} products:\n${
      output.results.map(r =>
        `- ${r.name} ($${r.price})`
      ).join('\n')
    }`;
  }
});

Tool Design

Follow verb-noun format with domain prefix:
// ✅ Good - clear and specific
useWebMCP({ name: 'posts_like', /* ... */ });
useWebMCP({ name: 'cart_add_item', /* ... */ });
useWebMCP({ name: 'user_update_profile', /* ... */ });

// ❌ Unclear
useWebMCP({ name: 'like', /* ... */ });        // Like what?
useWebMCP({ name: 'add', /* ... */ });         // Add where?
useWebMCP({ name: 'doUpdate', /* ... */ });    // Update what?
Help the AI understand when to use each tool:
// ✅ Good - specific and actionable
useWebMCP({
  name: 'cart_add_item',
  description: 'Add a product to the user\'s shopping cart with specified quantity',
  // ...
});

// ❌ Too vague
useWebMCP({
  name: 'cart_add_item',
  description: 'Adds item',
  // ...
});

// ✅ Good - includes constraints
useWebMCP({
  name: 'schedule_meeting',
  description: 'Schedule a meeting between 9 AM and 5 PM EST on weekdays',
  // ...
});
Use annotations to guide AI behavior:
useWebMCP({
  name: 'get_user_info',
  description: 'Get user information',
  inputSchema: {},
  handler: async () => getCurrentUser(),
  annotations: {
    readOnlyHint: true,      // Safe to call repeatedly
    idempotentHint: true,    // Same result each time
    destructiveHint: false   // Doesn't modify data
  }
});

useWebMCP({
  name: 'delete_account',
  description: 'Permanently delete user account',
  inputSchema: { userId: z.string() },
  handler: async (input) => deleteUser(input.userId),
  annotations: {
    readOnlyHint: false,
    idempotentHint: false,
    destructiveHint: true    // Requires caution!
  }
});

Security

Check user permissions before executing sensitive operations:
useWebMCP({
  name: 'delete_post',
  description: 'Delete a blog post',
  inputSchema: {
    postId: z.string()
  },
  handler: async (input) => {
    // Check permissions first
    const user = getCurrentUser();
    const post = await getPost(input.postId);

    if (post.authorId !== user.id && !user.isAdmin) {
      throw new Error('Not authorized to delete this post');
    }

    await deletePost(input.postId);
    return { success: true };
  }
});
Never trust input data, even from validated schemas:
useWebMCP({
  name: 'update_bio',
  description: 'Update user bio',
  inputSchema: {
    bio: z.string().max(500)
  },
  handler: async (input) => {
    // Sanitize HTML to prevent XSS
    const sanitized = DOMPurify.sanitize(input.bio);

    await updateUserBio(sanitized);
    return { success: true };
  }
});
Prevent abuse with rate limiting:
const rateLimiter = new Map();

useWebMCP({
  name: 'send_email',
  description: 'Send an email',
  inputSchema: {
    to: z.string().email(),
    subject: z.string(),
    body: z.string()
  },
  handler: async (input) => {
    const userId = getCurrentUser().id;

    // Check rate limit (5 emails per hour)
    const count = rateLimiter.get(userId) || 0;
    if (count >= 5) {
      throw new Error('Rate limit exceeded. Try again later.');
    }

    await sendEmail(input);

    rateLimiter.set(userId, count + 1);
    setTimeout(() => {
      rateLimiter.delete(userId);
    }, 60 * 60 * 1000); // Reset after 1 hour

    return { success: true };
  }
});

Testing

Verify tools register correctly:
import { render } from '@testing-library/react';
import { McpClientProvider } from '@mcp-b/react-webmcp';

test('registers tools correctly', async () => {
  const { container } = render(
    <McpClientProvider client={testClient} transport={testTransport}>
      <MyComponent />
    </McpClientProvider>
  );

  // Wait for connection
  await waitFor(() => {
    expect(testClient.listTools()).resolves.toContainEqual(
      expect.objectContaining({ name: 'my_tool' })
    );
  });
});
Test components that use tools:
test('calls tool correctly', async () => {
  const mockHandler = jest.fn().mockResolvedValue({ success: true });

  function TestComponent() {
    useWebMCP({
      name: 'test_tool',
      description: 'Test',
      inputSchema: {},
      handler: mockHandler
    });

    return null;
  }

  render(
    <McpClientProvider client={testClient} transport={testTransport}>
      <TestComponent />
    </McpClientProvider>
  );

  // Trigger tool call
  await testClient.callTool({
    name: 'test_tool',
    arguments: {}
  });

  expect(mockHandler).toHaveBeenCalled();
});