Skip to main content

Build an MCP server

This guide walks through building a custom MCP server that Moveo can connect to as a tool provider for an AI agent. It covers the implementation, the tool-design choices that determine whether the agent calls your tools correctly, and the operational practices that keep the server reliable in production.

The examples use Next.js App Router with TypeScript, but the patterns translate to any HTTP framework.

note

For configuring an MCP server in the Moveo UI (adding a server, managing tools, resyncing) and for runtime behavior numbers, see MCP servers.

What you'll build

By the end of this guide you will have:

  1. A route that exposes an MCP endpoint over Streamable HTTP.
  2. One tool with a Zod-validated input schema and a description that the agent uses correctly.
  3. An authentication strategy you can pick from: no auth, header-based, URL-based, or OAuth.
  4. A working URL you can paste into Moveo to connect the agent.

Prerequisites

  • Node.js 18 or newer
  • A TypeScript project (Next.js, Express, Fastify, or any HTTP framework)
  • A public-facing HTTPS URL (production: Vercel, Cloud Run, Fly.io; local development: ngrok or similar)

Install dependencies

npm install mcp-handler @modelcontextprotocol/sdk zod

mcp-handler is a thin wrapper that hosts an MCP server over Streamable HTTP and provides an withMcpAuth helper for OAuth. @modelcontextprotocol/sdk is the official MCP SDK. zod validates tool inputs.

Create the route

Create app/api/your-server/mcp/route.ts:

import { createMcpHandler } from 'mcp-handler';
import { searchOrders } from './tools/search-orders';

export async function POST(request: Request): Promise<Response> {
const handler = createMcpHandler(
(server) => {
server.registerTool(
searchOrders.name,
{
title: searchOrders.name,
description: searchOrders.description,
inputSchema: searchOrders.inputSchema.shape,
},
searchOrders.handler
);
},
{
serverInfo: { name: 'your-server', version: '1.0.0' },
},
{
basePath: '/api/your-server',
disableSse: true,
}
);

return handler(request);
}

A few details that matter:

  • disableSse: true — Moveo only connects over Streamable HTTP. The legacy SSE transport is rejected at the URL-validation step.
  • basePath — must match the route path so the MCP handler resolves the correct endpoint.
  • The setup callback is where you register every tool the server exposes.

Define a tool

Create app/api/your-server/mcp/tools/search-orders.ts:

import { z } from 'zod';

export const searchOrders = {
name: 'search_orders',
description: 'Search the order database by order number or customer email.',
inputSchema: z.object({
query: z.string().describe('Order number or customer email to search for'),
limit: z.number().int().min(1).max(20).default(5),
}),
handler: async (args: { query: string; limit: number }) => {
const orders = await fetchOrders(args.query, args.limit);
if (orders.length === 0) {
return {
content: [
{ type: 'text' as const, text: `No orders found for "${args.query}".` },
],
};
}
const text = orders
.map((o) => `Order #${o.id}${o.status}, ${o.total} ${o.currency}`)
.join('\n');
return { content: [{ type: 'text' as const, text }] };
},
};

That's a working tool — but the description is too thin to make the agent call it reliably. The next section covers the tool-design choices that actually move the needle.


Tool design

A tool's name, description, and input schema are part of the system prompt the agent sees. Whether the agent calls the right tool at the right time depends much more on these strings than on the model behind the agent. Treat them as prompt copy.

The four surfaces the agent sees

Every tool exposes four prompt surfaces. Each is read by the model on every turn.

  1. Tool name — short identifier the agent uses to invoke the tool.
  2. Tool description — free text. The agent's primary signal for when to use the tool.
  3. Input schema — JSON Schema with field-level descriptions. The agent reads each field's description to fill in arguments.
  4. Tool result — what the tool returns. The agent uses this to compose a reply or chain into the next tool.

Edit all four like you'd edit a guideline.

Tool names

// Good
'search_orders'
'cancel_appointment'
'send_invoice'
'get_customer'

// Bad
'do_thing' // no information for the agent
'OrderSearch' // wrong case
'query' // generic, collides across tools
'fetch_data' // no domain
'order' // not a verb — is this get? search? create?
  1. Use lowercase snake_case. Most MCP SDKs require it; the model also handles it more reliably.
  2. Lead with a verb. The verb signals action.
  3. Use singular for "get one" and plural for "list/search". get_order returns one; search_orders returns many.
  4. Avoid generic names like query, fetch, lookup, data. They collide across tools and force the model to read every description.
  5. Match the user's vocabulary. If your support team says "ticket" and your database says "issue", name the tool around the user-facing word.
  6. Keep names stable. Renaming a tool flips the server status to Outdated and forces a resync. Old guidelines that referenced the old name break.

Tool descriptions

The description is where the agent learns when to call the tool. The model is forced to choose between similarly-named tools by reading these few sentences. Make them count. The same string also appears as a tooltip in the Moveo UI when an operator hovers a tool in the Edit panel — write it for both audiences.

A reliable structure is WHAT, WHEN, HOW, RETURNS, NOT:

description: `Search the order database by order number or customer email.

WHAT: Returns matching orders with their status, total, and last-updated date.

WHEN:
- Customer asks about a specific order they placed
- Customer mentions an order number, email, or "my order"
- Customer asks about delivery status of an existing order

HOW:
- query: order number (numeric, 6+ digits) or customer email address
- limit: defaults to 5; raise only if the customer explicitly asks for more

RETURNS: Up to N orders, leading with the most recent. Empty list if no match.

NOT: For shipping policy or returns, use the knowledge base. For creating new orders, use \`create_order\`. For order line items, use \`get_order_details\`.`

The NOT section is the highest-leverage. Whenever two tools collide in the agent's mind, fix it by writing a better NOT in one description, not by adding rules to a guideline.

Other tips:

  1. Start the description with the verb. The model often reads only the first line under time pressure.
  2. Include domain vocabulary the user will use. If users say "service appointment", put that phrase in the description even if your code calls it service_booking.
  3. State side effects. If the tool sends an email or charges a card, say so explicitly. Otherwise the agent might call it for exploration and surprise the user.
  4. Keep it under ~200 words. Long descriptions waste context, and the model often skims.

Input schemas

The agent reads the schema to know what arguments to send. It honors types, enums, and describe strings. Make the schema do as much work as possible.

// Good
inputSchema: z.object({
query: z.string().min(1)
.describe('Order number or customer email to search for'),
status: z.enum(['active', 'cancelled', 'pending', 'completed'])
.optional()
.describe('Filter results to a single status'),
limit: z.number().int().min(1).max(20).default(5)
.describe('Maximum number of results'),
})

// Bad
inputSchema: z.object({
q: z.string(), // no description, ambiguous name
filters: z.record(z.any()), // free-form, no constraints
options: z.object({ // deep nesting
pagination: z.object({
offset: z.number(),
page_size: z.number(),
}),
}),
})
  1. Describe every field. A field with no description is a coin flip.
  2. Use enums for known sets. Free-form strings invite typos.
  3. Set sensible defaults. The agent will call the tool with fewer arguments and fewer mistakes.
  4. Mark required fields explicitly. The model treats a missing required field as a hard error — which is what you want when the user hasn't provided the information yet.
  5. Keep it flat. Deeply nested objects confuse the model.
  6. Validate at the edges. Add min/max, pattern, format constraints the model can read. Even though Moveo validates arguments before forwarding, the agent uses these hints when constructing the call.
  7. Don't expose internal IDs. If your tool needs an account_id the user has never typed, the agent can't fill it. Read it from the Moveo session context instead.
  8. Use units in field names. Prefer delay_seconds over delay, price_cents over price. The model picks up on units in field names and rarely converts incorrectly.

Tool results

The string the tool returns is appended to the conversation as if the tool just spoke. Write it for the agent to summarize — not for an end user to read directly.

// Good — leads with the answer, formatted for paraphrasing
return {
content: [{
type: 'text' as const,
text: `Found 3 orders for jane@example.com:

**Order #4521** — shipped, $89.00 USD, updated Mar 2
**Order #4498** — delivered, $145.50 USD, updated Feb 28
**Order #4399** — refunded, $32.00 USD, updated Feb 14

Ask which one the customer is asking about.`,
}],
};

// Bad — JSON dump
return {
content: [{
type: 'text' as const,
text: JSON.stringify(orders),
}],
};
  1. Lead with the answer. Don't bury it under metadata.
  2. Use markdown sparingly. Bold for emphasis, lists for enumerations. Tables sometimes confuse smaller models.
  3. Cap volume. Return at most 5–10 results. The agent can ask the user to narrow down.
  4. Include enough for paraphrasing. If the agent will ask "is this the right one?", the result needs to contain the disambiguating fields.
  5. Tell the agent what to do next when ambiguous. "Multiple matches — ask the customer for the order number" inside the result text is more effective than relying on guidelines.
  6. Use the result, not the description, for things that change. Inventory counts, prices, and status belong in the result. The description is static.

Error messages

When something goes wrong, return a tool result with isError: true and a clear message.

// Good — actionable, plain language
return {
content: [{
type: 'text' as const,
text: 'Order #4521 not found. The order number may be wrong, or it could belong to a different account. Ask the customer to confirm the number.',
}],
isError: true,
};

// Bad — leaks implementation, not actionable
return {
content: [{
type: 'text' as const,
text: '404 NOT_FOUND: SELECT * FROM orders WHERE id=4521 returned 0 rows',
}],
isError: true,
};
  1. Tell the agent what happened in plain language. Not error codes.
  2. Tell the agent what to do next. This becomes the agent's natural reply.
  3. Distinguish recoverable from terminal errors. "Service is temporarily unavailable, retry in 30 seconds" is recoverable. "This account is closed" is not.
  4. Don't leak implementation. Stack traces, internal hostnames, SQL errors are noise to the model and risk to your operation.
  5. Match the user's language. If the conversation is in Spanish, errors should be in Spanish.

Disambiguating between tools

Two tools that look similar from the agent's perspective will be confused. Diagnose with these questions:

  1. Are the names verb-distinct? get_order vs. search_orders is good. get_order vs. find_order is a coin flip.
  2. Do the WHEN sections actually differ?
  3. Does each description name the other? "Use this for one order by ID; use search_orders when the user gives a name or email instead" cross-references reliably.
  4. Could it be one tool with a mode parameter?
// Two close-cousin tools — the agent has to pick the right one
{
name: 'find_order_by_id',
description: 'Look up an order by its numeric ID.',
inputSchema: z.object({ id: z.string() }),
}
{
name: 'find_order_by_email',
description: 'Find orders for a customer by email address.',
inputSchema: z.object({ email: z.string().email() }),
}

// One tool with a mode — the agent picks from a known enum
{
name: 'find_order',
description: 'Find orders by ID or by customer email.',
inputSchema: z.object({
query: z.string().describe('Order ID or customer email'),
by: z.enum(['id', 'email']).describe('How to interpret the query'),
}),
}

Iteration loop

Tool prompts are like guideline copy: ship a draft, watch the model use it, refine.

  1. Run the agent in the test panel with realistic user messages that should trigger the tool.
  2. Watch the trace for two failure modes:
    • The agent does not call the tool when it should. The description's WHEN section is too narrow or the name is wrong.
    • The agent calls the tool when it shouldn't. Add a NOT clause that rules out the case it just got wrong.
  3. Watch for fishing. Repeated calls with slight argument variations mean the model doesn't trust the result format. Make the result more decisive.
  4. Watch for hallucination. If the agent describes calling the tool ("Let me search the orders…") without actually calling it, the description is unclear about whether the tool needs to be invoked vs. mentioned. Tighten WHAT to "Returns…" rather than "Helps with…".
  5. Refine the tool, not the prompt. When in doubt, the fix lives in the tool definition. Guidelines that say "remember to call search_orders" are a smell — the description should make the call self-evident.

Anti-patterns

These keep recurring. Audit your tools for them.

  • Tool name as an action. do_thing, process_request, handle_input.
  • Description as documentation. Long parameter docs, "see also" links, deprecation notices.
  • Free-form strings where enums belong. A status field that takes any string. The agent will invent values.
  • Identical names with different shapes. get_user on two different MCP servers, with different schemas. Moveo does not let the agent disambiguate by server. Rename one.
  • Returning JSON to the agent. The agent reads the result as text. Return prose or markdown that summarizes the JSON, not the JSON itself.
  • One mega-tool with mode covering 12 use cases. Fan out into 2–4 specific tools instead.
  • Side effects with no warning. A tool named lookup_customer that secretly creates a CRM contact. Either rename or split.

Referencing tools from guidelines

Once your tool is connected in Moveo, the people writing the agent's guidelines can mention it inline. In any guideline editor (Overview, Features, Custom Instructions, Objections), typing @ opens a picker that lists every enabled tool. Selecting one inserts an inline badge.

A realistic guideline reads like business prose with tool references woven in:

When the customer asks about a specific order or mentions an order number, use @search_orders to find it. If multiple orders match, ask the customer to confirm which one before continuing.

If the customer wants to cancel, only use @cancel_order after confirming the order number out loud — never cancel based on a partial match.

Two practical implications for tool design:

  1. Names appear verbatim in guidelines. A clean name like search_orders reads naturally; do_search_v2_final does not. Renames break every guideline that referenced the old name.
  2. Tools can be enabled per-guideline. Disabling a tool in a guideline removes its references automatically. Build small, focused tools that can be selectively enabled — large catch-all tools become unwieldy. The operator selects which tools are enabled per server in the Edit panel.

The agent reads both the guideline copy and the tool description. The guideline tells it the workflow; the description tells it what the tool does. Keep them aligned: if the guideline says "search orders by email or order number", the description's WHEN section should match.


Authentication

Pick the simplest authentication that meets your security requirements. Moveo's MCP servers UI supports four modes; this section shows how to implement the server side of each.

No authentication

If your tools are read-only and your endpoint URL is hard to enumerate, public access is acceptable. The route shown above accepts any caller.

In Moveo, when you add the server, the Authentication mode is detected as None and the connection is verified automatically. See No authentication in the UI walkthrough.

Header authentication

Validate a static credential that Moveo passes on every request:

export async function POST(request: Request): Promise<Response> {
const apiKey = request.headers.get('x-api-key');
if (apiKey !== process.env.MCP_API_KEY) {
return new Response('Unauthorized', { status: 401 });
}

const handler = createMcpHandler(/* ... */);
return handler(request);
}

In Moveo:

  1. Add the server with the URL of your route.
  2. Discovery returns 401 because no header is set yet.
  3. Choose Header authentication and add a header row: key x-api-key, value <your secret>.
  4. Select Connect.

For the full UI flow, see Header authentication in the MCP servers walkthrough.

Header auth fits server-to-server integrations where you can rotate a static secret on a regular schedule. Common conventions:

ConventionHeader
Bearer tokenAuthorization: Bearer <token>
Custom API keyX-Api-Key: <key>
Vendor-specificX-<Vendor>-Token: <token>

URL-based authentication

Embed a token directly in the URL path:

// app/api/your-server/mcp/[token]/route.ts
import { createMcpHandler } from 'mcp-handler';

export async function POST(
request: Request,
{ params }: { params: { token: string } }
): Promise<Response> {
if (params.token !== process.env.MCP_URL_TOKEN) {
return new Response('Not Found', { status: 404 });
}

const handler = createMcpHandler(
(server) => { /* register tools */ },
{ serverInfo: { name: 'your-server', version: '1.0.0' } },
{
basePath: `/api/your-server/mcp/${params.token}`,
disableSse: true,
}
);
return handler(request);
}

In Moveo, paste the URL with the embedded token. Discovery succeeds because the URL itself is the credential, so the Authentication mode is detected as None.

URL-based auth is convenient for per-tenant URLs — give each customer their own token-embedded URL and the server can route to the right tenant from the path. It is not recommended for long-lived production secrets because the token typically appears in HTTP access logs, CDN caches, and observability tools.

OAuth

For full OAuth 2.0 with PKCE, wrap the handler with withMcpAuth:

import { createMcpHandler, withMcpAuth } from 'mcp-handler';
import { verifyToken } from '@/util/auth';

export async function POST(request: Request): Promise<Response> {
const handler = withMcpAuth(
createMcpHandler(/* ... */),
verifyToken,
{
required: false,
resourceMetadataPath:
'/.well-known/oauth-protected-resource/<your-issuer>',
}
);
return handler(request);
}

verifyToken is your function that takes the incoming Request and a Bearer token, and returns either an AuthInfo object on success or undefined to reject. A typical implementation verifies against the authorization server's JWKS:

import { createRemoteJWKSet, jwtVerify } from 'jose';

const JWKS = createRemoteJWKSet(new URL('https://issuer.example.com/oauth2/jwks'));

export async function verifyToken(req: Request, bearerToken?: string) {
if (!bearerToken) return undefined;

try {
const { payload } = await jwtVerify(bearerToken, JWKS, {
issuer: 'https://issuer.example.com',
});
return {
token: bearerToken,
clientId: payload.sub || '',
scopes: payload.permissions || [],
};
} catch {
return undefined;
}
}

Provide an OAuth Resource Metadata endpoint at the path you specify in resourceMetadataPath, returning the authorization server's discovery URL. Moveo follows that metadata to negotiate the rest of the flow, including dynamic client registration if your provider supports RFC 7591. For the user-facing flow — popup, dynamic registration, manual client credentials fallback — see OAuth authentication.

If your server uses OAuth, support refresh tokens and document any scopes you require. Moveo stores access and refresh tokens for the connection but cannot recover from a revoked refresh token without a fresh authorization flow.

What Moveo sends to your server

On every tool call, Moveo includes correlation headers so you can log, route, and rate-limit per tenant:

HeaderDescription
x-moveo-account-idAccount that owns the conversation
x-moveo-account-slugAccount slug
x-moveo-brain-idIdentifier of the AI agent making the call
x-moveo-desk-idDesk identifier
x-moveo-session-idConversation session
x-moveo-external-idExternal user identifier
x-moveo-request-idRequest correlation ID
x-moveo-user-agentMoveo user-agent string

In addition, conversation context is passed in the MCP request _meta field. From inside a tool handler, access it via the extra parameter:

handler: async (args, { extra }) => {
const moveoContext = extra._meta?.['moveo/context']?.context;
const language = extra._meta?.['moveo/context']?.lang;
const channel = extra._meta?.['moveo/context']?.channel;
// ...
}

The context object holds the agent's session variables. Set them from the agent side using a live-instructions webhook before the tool call.

Update Moveo session context from a tool

Tools can write back to the agent's session variables by including a _meta field in the response:

return {
content: [{ type: 'text' as const, text: 'Found 3 orders.' }],
_meta: {
'moveo/context': {
context: {
last_search_query: args.query,
last_search_count: orders.length,
},
},
},
};

The new variables are merged into the session and become available to subsequent tool calls and guidelines (where they can be referenced as $last_search_query).

Deploy and connect from Moveo

  1. Deploy your server to a public HTTPS URL.
  2. In Moveo, navigate to your AI agent → WorkflowsMCP serversAdd MCP server.
  3. Paste the full URL of your route (for example, https://your-server.example.com/api/your-server/mcp).
  4. Pick the matching authentication mode and complete the auth step.
  5. Select the tools to expose and save.

For the full configuration walk-through in the Moveo UI, see MCP servers.

Test locally

Use a tunnel like ngrok to expose localhost over HTTPS, then connect from Moveo with that URL.

For automated testing without Moveo, send a JSON-RPC tools/list request directly:

curl -X POST https://your-server.example.com/api/your-server/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

A successful response lists the registered tools with their input schemas.

Operating your MCP server in production

Tool calls happen inside live conversations. The runtime numbers (timeouts, retries, validation, cache TTL) are documented under Runtime behavior. The practices below are how you build for them.

Respect the connection budget

Moveo's connection and discovery handshake is bounded by a 30-second timeout. If your server depends on cold-start work — warming a database connection, fetching auth metadata, loading a model — do that lazily rather than blocking the handshake. The first request can pay the warmup; the handshake should not.

Keep tool calls fast

Tools are called inside a live conversation. Long-running work should either complete in a few seconds or run asynchronously and return a status the agent can poll. Avoid synchronous tool calls that take longer than the user is willing to wait.

Implement timeouts on the server side

Wrap any outbound call your tool makes (HTTP requests, database queries, third-party APIs) with its own timeout that is shorter than the Moveo tool-call budget (65 seconds). Never let an unbounded operation hang the call.

Make tool calls idempotent

Moveo retries connection-level failures and HTTP 5xx/429 once with exponential backoff. Tool-level errors that you return with isError: true are passed back to the agent without retrying — that's a deliberate signal. Idempotent tools (same input → same effect) let Moveo retry transport failures safely, and let the agent re-issue a call without duplicating side effects.

Add retries inside the tool for transient failures

If your tool depends on flaky downstream services, retry inside the server with bounded attempts and exponential backoff. Surface a single, clear error to Moveo only when retries are exhausted.

Version tool definitions deliberately

Changes to a tool's description or input schema flip the server status to Outdated. Plan changes so users can resync at a predictable time, and avoid removing a tool without a deprecation window — guidelines that referenced the removed tool will lose their references.

Next steps

  • MCP servers — connect your built server to Moveo and manage it
  • Local tools — built-in tools that don't require an external server
  • Live instructions — set session context that MCP tools can read