Expose your tools over MCP
Serve your site's tools as an MCP server, let the visitor's model call them through the tool loop or the AI SDK you already use, and design them so the model finds what the visitor asked for.
Your site's AI features need your site's data: a catalog search, the details of one item, the filters your catalog accepts. AI Connect reads these tools from an MCP server you run. The visitor's model decides which tool to call, and the SDK calls your server from the visitor's browser.
The worked example on this page is Taamtaam, a directory of kosher restaurants in Israel and Miami. Its "AI mode" lets a visitor describe a mood or an occasion, and their own model searches the catalog through three tools: list_filters, search_restaurants and get_restaurant. Taamtaam serves the same tools to the connector visitors add in Claude or ChatGPT.
How the SDK reads your tools
McpToolSource is the MCP client of @aiconnect/core. It runs in the page.
import { McpToolSource } from '@aiconnect/core';
const tools = new McpToolSource('/api/mcp', 'TaamTaam');- The first argument is your server's address. A relative path resolves against the page's address.
- The second argument is the client name your server sees when the client initializes. It defaults to
aiconnect. - The client connects on first use over Streamable HTTP and reuses the connection.
listTools()reads the tool list once and keeps it for the life of the instance. Create the source once per page or panel, outside your render functions.callTool(name, args)returns the text parts of the result joined with line breaks. Other content types are dropped, so return your data as text, usually JSON.- A result with
isError: truecomes back asTool error:followed by its text. The model reads it like any other result.
When the client opens the connection, it also sends a GET to your endpoint to ask for a server stream. A stateless server answers 405 Method Not Allowed, which the MCP specification defines as the answer of a server without a stream, and the client goes on with POST requests. A 405 on GET in your logs is expected.
The tool loop
useAiChat in React, or runConversation without it, runs the loop for each question:
- It lists your tools, then sends the conversation and the tool definitions to the visitor's model through the wallet.
- When the model's answer asks for tools, it calls them one after the other, in the order the model asked, and adds each result to the conversation as a
toolmessage that carries the call's id. - It sends the longer conversation back to the model, and repeats until the model answers without tool calls.
- The last step offers no tools, so the model has to conclude. The default is 6 steps, which you change with the
maxStepsoption ofrunConversation.
A tool that throws becomes a Tool error: result as well, so a failing tool does not end the conversation. The model can try again or tell the visitor what went wrong.
import { finalAnswer, runConversation } from '@aiconnect/core';
const added = await runConversation({
session: state.session,
tools,
messages: [{ role: 'system', content: SYSTEM_PROMPT }, { role: 'user', content: question }],
maxSteps: 4,
onToolCall: (call) => console.log('calling', call.name, call.arguments),
onToolResult: (call, result) => console.log(call.name, 'returned', result.length, 'characters'),
});
const answer = finalAnswer(added)?.content;With an AI SDK
A site that runs its conversation through the AI SDK it already uses, as the AI SDK guide describes, lets that SDK run the loop. Your MCP server still serves the tools: read them with McpToolSource, declare them in the SDK's format, and send each call back to your server.
import { McpToolSource } from '@aiconnect/core';
import { createOpenAI } from '@ai-sdk/openai';
import { jsonSchema, stepCountIs, streamText, tool } from 'ai';
const source = new McpToolSource('/api/mcp', 'TaamTaam');
const openai = createOpenAI({ apiKey: aiconnect.sdkOptions.apiKey, fetch: aiconnect.fetch });
const tools = Object.fromEntries((await source.listTools()).map((definition) => [definition.name, tool({
description: definition.description,
inputSchema: jsonSchema(definition.inputSchema),
execute: (args) => source.callTool(definition.name, args),
})]));
const result = streamText({
model: openai('gpt-6-luna'), system: SYSTEM_PROMPT, prompt: question, tools, stopWhen: stepCountIs(4),
});With the OpenAI SDK, each definition becomes { type: 'function', function: { name, description, parameters: inputSchema } }, and each tool call the model makes goes to source.callTool(name, args). The other SDKs follow the same pattern in their own tool format.
Serve it from your site
The browser calls your MCP server, so the server has to answer your page's origin. Serving it under your own domain, at a path such as /api/mcp, avoids any CORS setup.
The server
Taamtaam's backend runs Express and the official MCP SDK. The server is stateless: each POST gets a new server and a new transport, and answers in JSON. The tools read published data only, so the endpoint needs no authentication, and a rate limiter damps scripted bursts.
const mcpRouter = require('express').Router()
const { StreamableHTTPServerTransport } = require('@modelcontextprotocol/sdk/server/streamableHttp.js')
const { buildCatalogMcpServer } = require('../services/restaurantCatalog/catalogMcpServer')
// Taamtaam's own helpers: a rate limiter keyed by the visitor's address, and its logger.
const { createRateLimiter } = require('../utils/rateLimit')
const { clientIpKey } = require('../utils/clientIp')
const logger = require('../utils/logger')
const catalogLimiter = createRateLimiter({
windowMs: 60 * 1000,
max: 60,
keyFn: clientIpKey('mcp-catalog'),
message: 'Too many catalog requests from this connection. Please try again in a minute.'
})
mcpRouter.post('/catalog', catalogLimiter, async (request, response) => {
const server = buildCatalogMcpServer()
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true })
response.on('close', () => {
transport.close()
server.close()
})
try {
await server.connect(transport)
await transport.handleRequest(request, response, request.body)
} catch (error) {
logger.error('MCP catalog request failed:', error)
if (!response.headersSent) {
response.status(500).json({ error: 'MCP request failed' })
}
}
})
// The stateless transport serves POST only.
const methodNotAllowed = (request, response) => response.status(405).json({ error: 'Method not allowed' })
mcpRouter.get('/catalog', methodNotAllowed)
mcpRouter.delete('/catalog', methodNotAllowed)
module.exports = mcpRouterThe router is mounted with app.use('/api/mcp', mcpRouter), so the server answers at /api/mcp/catalog. catalogLimiter allows 60 requests a minute per visitor address. A model answering one question calls a few tools, which leaves room for several questions a minute.
The tools themselves are registered on an McpServer:
const { z } = require('zod')
const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js')
const { FEATURE_FIELDS, MAX_LIMIT } = require('./catalogQuery')
const catalog = require('./index')
const Restaurant = require('../../models/restaurant')
const READ_ONLY = { readOnlyHint: true, openWorldHint: false }
const jsonResult = (value) => ({ content: [{ type: 'text', text: JSON.stringify(value) }] })
const registerFilters = (server) => server.registerTool('list_filters', {
title: 'Search filters',
description: 'The values search_restaurants accepts: cities, cuisines, ambiances, styles, kashrut '
+ 'certifications, features and the price scale.',
inputSchema: {},
annotations: READ_ONLY
}, async () => jsonResult(await catalog.listFacets()))
const buildCatalogMcpServer = () => {
const server = new McpServer({ name: 'taamtaam', version: '1.0.0' })
registerSearch(server)
registerDetails(server)
registerFilters(server)
return server
}registerSearch and registerDetails appear in the design rules below.
The relay
Taamtaam's backend runs on its own host, and the Next.js site relays /api/mcp to it, so the browser talks to the site's own origin. A relay forwards the POST body and the headers the MCP client sends:
import { NextResponse } from 'next/server';
// The address of the MCP server, such as http://localhost:4000/api/mcp/catalog.
const MCP_SERVER_URL = process.env.MCP_SERVER_URL;
const FORWARDED_HEADERS = ['accept', 'content-type', 'mcp-protocol-version'];
export async function POST(request) {
const headers = Object.fromEntries(
FORWARDED_HEADERS.filter((name) => request.headers.has(name)).map((name) => [name, request.headers.get(name)]),
);
const upstream = await fetch(MCP_SERVER_URL, { method: 'POST', headers, body: await request.text() });
return new Response(upstream.body, {
status: upstream.status,
headers: { 'content-type': upstream.headers.get('content-type') ?? 'application/json' },
});
}
const methodNotAllowed = () => NextResponse.json(
{ error: 'Method not allowed' },
{ status: 405, headers: { Allow: 'POST' } },
);
export const GET = methodNotAllowed;
export const DELETE = methodNotAllowed;
export const runtime = 'nodejs';Taamtaam's own relay also caps the size of the body it reads, and passes the visitor's address to the backend so the rate limit counts each visitor apart.
Design the tools for a model
The model reads only your tool names, your descriptions and your input schemas. Everything it needs to pick a tool and fill its arguments has to be in that text.
Name tools after what they do
Use a verb and a noun in snake case: list_filters, search_restaurants, get_restaurant. The system prompt and the other descriptions can then refer to a tool by its name, as in "use the slug returned by search_restaurants".
Give the model the accepted values
Free text filters fail when the catalog spells a value differently. Taamtaam's list_filters returns the cities, cuisines, ambiances, styles, certifications, features and price scale the catalog holds, and the search tool's descriptions point to it: "values from list_filters". The system prompt asks the model to call it once at the start.
Where the set of values is fixed, put it in the schema as an enum. Taamtaam reads the kosher types from its database schema and the features from its query module:
// The kosher types the restaurant schema accepts.
const KOSHER_TYPES = Restaurant.schema.path('type').enumValues
const SEARCH_INPUT = {
kosherType: z.enum(KOSHER_TYPES).optional(),
features: z.array(z.enum(Object.keys(FEATURE_FIELDS))).optional()
}Explain every value in the description
An enum lists the values that exist, and the description has to say what each one means. Taamtaam's early search tool listed the kosher types without explaining them. ChatGPT then returned empty results, and the cause was that description, which did not explain the Bassari & Halavi value. The description now explains each value and when to leave the filter out:
const SEARCH_INPUT = {
kosherType: z.enum(KOSHER_TYPES).optional()
.describe('Bassari serves meat, Halavi serves dairy, Vegan serves neither, and "Bassari & Halavi" is the rare '
+ 'place with both a meat and a dairy kitchen. Leave it out when the visitor has no preference'),
features: z.array(z.enum(Object.keys(FEATURE_FIELDS))).optional()
.describe('Features every result must have; dateFriendly marks places suited to a shidduch date')
}Explain domain words, units and scales the same way: minPrice and maxPrice say "Price level, scale from list_filters".
Ask for the filters the visitor named
Each filter narrows the results, and a model that fills every field it can guess often narrows the search to nothing. Say so in the tool's description:
const registerSearch = (server) => server.registerTool('search_restaurants', {
title: 'Search kosher restaurants',
description: 'Search the kosher restaurants listed on TaamTaam in Israel and Miami. Translate the visitor\'s mood '
+ 'and needs into filters; call list_filters first to learn the accepted values. Send only the filters the '
+ 'visitor asked for, since each one narrows the results. Returns at most '
+ `${MAX_LIMIT} restaurants, best rated first, each with its page url. If nothing matches, relax a filter.`,
inputSchema: SEARCH_INPUT,
annotations: READ_ONLY
}, async (filters) => jsonResult(await catalog.searchRestaurants(filters)))Bound every input
The arguments come from a model, so cap them in the schema: string lengths, array sizes and a maximum limit.
const textList = (description) => z.array(z.string().max(80)).max(10).optional().describe(description)
// Part of the search schema; the full one also holds the kashrut, style, feature and price filters.
const SEARCH_INPUT = {
city: z.string().max(40).optional().describe('City slug from list_filters, such as telaviv or jerusalem'),
cuisines: textList('Cuisines (any of), values from list_filters'),
ambiances: textList('Ambiances (any of), values from list_filters, such as Romantic or Lively'),
text: z.string().max(80).optional().describe('Words searched in the name, description and neighborhood'),
limit: z.number().int().min(1).max(MAX_LIMIT).optional()
}Taamtaam returns 12 restaurants by default and 25 at most. Short results keep the conversation small, which matters on a visitor's plan with usage limits.
Decide what an empty result says
A search that matches nothing returns an empty list, []. The description tells the model what to do next ("If nothing matches, relax a filter"), and the system prompt repeats it ("When a search returns nothing, relax one filter and search again").
A lookup of one item that does not exist returns an error result with a short reason. The SDK hands it to the model as Tool error: No such restaurant.
const registerDetails = (server) => server.registerTool('get_restaurant', {
title: 'Restaurant details',
description: 'Everything TaamTaam publishes about one restaurant: address, opening hours, review, FAQ, phone '
+ 'and booking link. Use the slug returned by search_restaurants.',
inputSchema: {
city: z.string().max(40).describe('slug.city from search_restaurants'),
name: z.string().max(120).describe('slug.name from search_restaurants')
},
annotations: READ_ONLY
}, async (slug) => {
const restaurant = await catalog.getRestaurant(slug)
if (!restaurant) {
return { isError: true, content: [{ type: 'text', text: 'No such restaurant' }] }
}
return jsonResult(restaurant)
})Return what the next step needs
Each search result carries the restaurant's id, its slug with city and name, its page URL and the fields the site's cards show. The model passes the slug to get_restaurant, and the page uses the same JSON to render its cards.
Mark tools that only read
Taamtaam annotates every tool with readOnlyHint: true and openWorldHint: false. These annotations tell MCP clients that the tools change nothing and reach no outside system.
Write the system prompt with the tools
The system prompt sets the order of the calls and the shape of the answer. Taamtaam's prompt:
export const SYSTEM_PROMPT = [
'You are the restaurant guide of TaamTaam, a curated list of kosher restaurants in Israel and Miami.',
'Recommend only restaurants returned by the tools. Call list_filters once to learn the accepted values, then',
'search_restaurants with the filters that match the visitor\'s mood, occasion, city, kashrut and budget.',
'When a search returns nothing, relax one filter and search again.',
'Suggest three restaurants at most. For each, give its exact name as returned, and one sentence on why it',
'fits. Ask one short question when the city or the meat or dairy choice is missing and matters.',
'Answer in the visitor\'s language, briefly, in plain text.',
].join(' ');"Give its exact name as returned" lets the page match the answer to the search results.
Show tool results as your own cards
useAiChat keeps every tool call of a turn with its raw result in turn.tools. Taamtaam parses the search results and keeps the restaurants the answer names, in the order the answer names them:
const SEARCH_TOOL = 'search_restaurants';
function parseRestaurants(result) {
try {
const parsed = JSON.parse(result);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
export function suggestedRestaurants(turn) {
const answer = turn.answer.toLowerCase();
const found = new Map();
for (const { call, result } of turn.tools) {
if (call.name !== SEARCH_TOOL || !result) {
continue;
}
for (const restaurant of parseRestaurants(result)) {
found.set(restaurant.id, restaurant);
}
}
return [...found.values()]
.map((restaurant) => ({ restaurant, position: answer.indexOf(restaurant.name.toLowerCase()) }))
.filter(({ position }) => position >= 0)
.sort((a, b) => a.position - b.position)
.map(({ restaurant }) => restaurant);
}The hook that feeds the panel adds those restaurants to each turn:
import { useMemo } from 'react';
import { McpToolSource } from '@aiconnect/core';
import { useAiChat } from '@aiconnect/react';
import { MCP_PATH, SITE_NAME, SYSTEM_PROMPT } from '../constants/moodSearch';
import { suggestedRestaurants } from '../utils/suggestedRestaurants';
export default function useMoodSearch() {
const tools = useMemo(() => new McpToolSource(MCP_PATH, SITE_NAME), []);
const chat = useAiChat({ system: SYSTEM_PROMPT, tools });
const turns = useMemo(
() => chat.turns.map((turn) => ({ ...turn, restaurants: suggestedRestaurants(turn) })),
[chat.turns],
);
return { ...chat, turns };
}While a tool runs, its result is null. Taamtaam uses that to show a status line per tool, such as "Searching restaurants" while search_restaurants runs.
Tools without an MCP server
useAiChat and runConversation accept any object with listTools() and callTool(name, args), and so does the mapping for an AI SDK above. The bookshop demo serves its catalog from memory this way, for its chat hook and for each SDK of its SDK mode; the React quickstart shows it. An MCP server lets one tool catalog serve your page and the connectors visitors add in the provider apps.
Use the AI SDK you already have
Keep your OpenAI, Anthropic, Google Gen AI, Vercel AI SDK, LangChain or LlamaIndex code and let it answer on the visitor's own AI account by passing one option to the SDK, and serve a Mastra agent's tools to the page over MCP.
Theming
Match the connect window to your design system with the CSS tokens of @aiconnect/design, in light and dark, and in right to left languages.