Tools are the entry point of your app: the model reads their metadata and triggers them when they fit the conversation. Think of them as an API: each tool has one job, and jobs can complement each other. Bind a view to a tool, and the host mounts it inline in the conversation. The model isn’t the only caller: the user can trigger tools too, via the view .
Here’s a complete tool definition:
import { McpServer } from "skybridge/server" ;
import { z } from "zod" ;
const server = new McpServer ({ name: "personal-shopper" , version: "0.0.1" }, {})
. registerTool (
{
name: "search-products" ,
title: "Search Products" ,
description: "Show products matching a query in a carousel." ,
inputSchema: { query: z . string (). describe ( "Full-text product search" ) },
outputSchema: {
products: z . array (
z . object ({ id: z . string (), name: z . string (), price: z . number () }),
),
},
annotations: { readOnlyHint: true , openWorldHint: false , destructiveHint: false },
view: { component: "carousel" },
},
async ({ query }) => {
const products = await search ( query );
return {
content: `Found ${ products . length } products for " ${ query } ".` ,
structuredContent: {
products: products . map (({ id , name , price }) => ({ id , name , price })),
},
_meta: { images: products . map (( p ) => p . imageUrl ) },
};
},
);
The sections below build this definition up piece by piece: the server it lives on, the metadata and schemas the model reads, the handler, the behavior annotations, host-specific tuning, and the view binding.
Create the Server
McpServer takes your app’s identity and returns a chainable server. Chain every registerTool call, then run():
const server = new McpServer ({ name: "personal-shopper" , version: "0.0.1" }, {})
. registerTool ( /* … */ )
. registerTool ( /* … */ );
server . run ();
export type AppType = typeof server ;
Chaining accumulates your tool signatures into one type. Exporting AppType is what makes the generated view hooks type-safe end to end.
Describe It to the Model
server . registerTool (
{
name: "search-products" ,
title: "Search Products" ,
description: "Show products matching a query in a carousel." ,
inputSchema: { query: z . string (). describe ( "Full-text product search" ) },
outputSchema: {
products: z . array (
z . object ({ id: z . string (), name: z . string (), price: z . number () }),
),
},
annotations: { readOnlyHint: true , openWorldHint: false , destructiveHint: false },
view: { component: "carousel" },
},
async ({ query }) => {
/* … */
},
);
name identifies the tool. Keep it kebab-case and verb-led: search-products, create-checkout.
title is the human-readable display name.
description is read to decide when to call the tool.
These are prompt engineering. Write them for the model: state what the tool does and what it shows, so the model can pick the right tool among many and narrate it correctly to the user.
Type the Contract
Schemas are plain Zod shapes. inputSchema declares what the model must provide. outputSchema is optional and declares the shape of the structured content returned to the model.
server . registerTool (
{
name: "search-products" ,
title: "Search Products" ,
description: "Show products matching a query in a carousel." ,
inputSchema: { query: z . string (). describe ( "Full-text product search" ) },
outputSchema: {
products: z . array (
z . object ({ id: z . string (), name: z . string (), price: z . number () }),
),
},
annotations: { readOnlyHint: true , openWorldHint: false , destructiveHint: false },
view: { component: "carousel" },
},
async ({ query }) => {
/* … */
},
);
.describe() on a field is more prompt surface: it tells the model how to fill the argument.
While it’s recommended to provide the output schema, it’s optional and isn’t used for type safety, which is inferred from the handler’s return type.
Write the Handler
The handler receives validated input and returns the tool’s response . Three fields, three audiences:
Field Consumed by contentThe model: what it reads and narrates from structuredContentThe model and the view _metaThe view only: never reaches the model
server . registerTool (
{
name: "search-products" ,
title: "Search Products" ,
description: "Show products matching a query in a carousel." ,
inputSchema: { query: z . string (). describe ( "Full-text product search" ) },
outputSchema: {
products: z . array (
z . object ({ id: z . string (), name: z . string (), price: z . number () }),
),
},
annotations: { readOnlyHint: true , openWorldHint: false , destructiveHint: false },
view: { component: "carousel" },
},
async ({ query }) => {
const products = await search ( query );
return {
content: `Found ${ products . length } products for " ${ query } ".` ,
structuredContent: {
products: products . map (({ id , name , price }) => ({ id , name , price })),
},
_meta: { images: products . map (( p ) => p . imageUrl ) },
};
},
);
Choosing the recipient is a design decision: it controls what context each actor sees. Keep content and structuredContent concise, since they’re model context. Use _meta for data the model has no use for or isn’t supposed to know (image URLs, sensitive record fields): the view still accesses it, the model never sees it.
Handlers also receive an extra argument carrying additional data such as auth info or client hints .
Annotate Behavior
Annotations tell the host how cautious to be. They drive confirmation prompts before invocation, and app directories check them at review time:
server . registerTool (
{
name: "search-products" ,
title: "Search Products" ,
description: "Show products matching a query in a carousel." ,
inputSchema: { query: z . string (). describe ( "Full-text product search" ) },
outputSchema: {
products: z . array (
z . object ({ id: z . string (), name: z . string (), price: z . number () }),
),
},
annotations: { readOnlyHint: true , openWorldHint: false , destructiveHint: false },
view: { component: "carousel" },
},
async ({ query }) => {
/* … */
},
);
readOnlyHint: only reads data, no side effects
destructiveHint: deletes or overwrites user data
openWorldHint: publishes content or reaches beyond the user’s account
Be honest with them: hosts trust these hints, and mislabeling a tool (claiming a write is read-only, hiding a destructive action) is a common cause of app directory rejection.
Tool-level _meta (distinct from the response _meta above) carries host-specific configuration:
server . registerTool (
{
name: "search-products" ,
title: "Search Products" ,
description: "Show products matching a query in a carousel." ,
inputSchema: { query: z . string (). describe ( "Full-text product search" ) },
outputSchema: {
products: z . array (
z . object ({ id: z . string (), name: z . string (), price: z . number () }),
),
},
annotations: { readOnlyHint: true , openWorldHint: false , destructiveHint: false },
view: { component: "carousel" },
_meta: {
// Expose the tool to the model, the view, or both (default)
ui: { visibility: [ "model" , "app" ] },
// ChatGPT status messages shown while the tool runs
"openai/toolInvocation/invoking" : "Searching the catalog…" ,
"openai/toolInvocation/invoked" : "Found products" ,
},
},
async ({ query }) => {
/* … */
},
);
Bind a View
Adding view to a tool makes the host mount a React component with the tool’s result. component names a file in views/:
server.ts
views/carousel.tsx
server . registerTool (
{
name: "search-products" ,
title: "Search Products" ,
description: "Show products matching a query in a carousel." ,
inputSchema: { query: z . string (). describe ( "Full-text product search" ) },
outputSchema: {
products: z . array (
z . object ({ id: z . string (), name: z . string (), price: z . number () }),
),
},
annotations: { readOnlyHint: true , openWorldHint: false , destructiveHint: false },
view: { component: "carousel" }, // resolves to views/carousel.tsx
},
async ({ query }) => {
/* … */
},
);
Each model call to the tool mounts a fresh view instance. Tools without view are headless: data, no UI.
All done! You now know how to register tools. Learn what happens inside the view in the next chapter.
Go Further
Create Views Craft interactive UIs rendered in conversation
Manage State Decide what the model sees
Authenticate Users Know who’s behind every tool call