# Expose your tools over MCP (/docs/guides/mcp-tools)

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 [#how-the-sdk-reads-your-tools]

`McpToolSource` is the MCP client of `@aiconnect/core`. It runs in the page.

```ts
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: true` comes back as `Tool 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 [#the-tool-loop]

`useAiChat` in React, or `runConversation` without it, runs the loop for each question:

1. It lists your tools, then sends the conversation and the tool definitions to the visitor's model through the wallet.
2. 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 `tool` message that carries the call's id.
3. It sends the longer conversation back to the model, and repeats until the model answers without tool calls.
4. The last step offers no tools, so the model has to conclude. The default is 6 steps, which you change with the `maxSteps` option of `runConversation`.

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.

```ts
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 [#with-an-ai-sdk]

A site that runs its conversation through the AI SDK it already uses, as the [AI SDK guide](/docs/guides/existing-sdks) 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.

```ts
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 [#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 [#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.

```js title="backend/controllers/mcp.js"
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 = mcpRouter
```

The 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`:

```js title="backend/services/restaurantCatalog/catalogMcpServer.js"
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 [#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:

```js title="src/app/api/mcp/route.js"
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 [#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 [#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 [#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:

```js
// 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 [#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:

```js
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 [#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:

```js
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 [#bound-every-input]

The arguments come from a model, so cap them in the schema: string lengths, array sizes and a maximum `limit`.

```js
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 [#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`.

```js
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 [#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 [#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 [#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:

```js title="src/features/ai-mode/constants/moodSearch.js"
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 [#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:

```js title="src/features/ai-mode/utils/suggestedRestaurants.js"
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:

```js title="src/features/ai-mode/hooks/useMoodSearch.js"
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 [#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](/docs/quickstart/react#try-it-without-a-server) shows it. An MCP server lets one tool catalog serve your page and the connectors visitors add in the provider apps.
