Register an MCP tool on the server. Provide a view to bind the tool to a React view; omit view for a plain tool with no UI.
Signature
server.registerTool(
config: ToolConfig,
handler: ToolHandler,
): McpServer
Config
type ToolConfig = {
name: string; // Tool name, exposed to the model
title?: string; // Human-readable title
description?: string; // Shown to the model
inputSchema?: ZodRawShape; // Input validation (Zod)
outputSchema?: ZodRawShape;// Output type hints (Zod)
annotations?: ToolAnnotations;
view?: ViewConfig; // ← present = view-tool, absent = plain tool
securitySchemes?: SecurityScheme[]; // Declare per-tool auth requirements
_meta?: ToolMeta;
};
type ToolMeta = {
// Enable view-initiated tool calls (see useCallTool)
"openai/widgetAccessible"?: boolean;
// Custom status messages
"openai/toolInvocation/invoking"?: string; // While running
"openai/toolInvocation/invoked"?: string; // After completion
"openai/fileParams"?: string[]; // List of top-level input fields that references files.
// Controls whether a tool is available to the model, the UI (app), or both.
ui?: { visibility?: Array<"model" | "app"> };
// Mirror of ToolConfig.securitySchemes
securitySchemes?: SecurityScheme[];
[key: string]: unknown;
};
type SecurityScheme =
| { type: "noauth" }
| { type: "oauth2"; scopes?: string[] };
Flag input parameters that ChatGPT can use to reference files by adding the keys to openai/fileParams.
Learn more about files lifecycle with tool calling by reading the FileRef doc.
view
Binds the tool to a React view.
type ViewConfig = {
component: ViewName; // File name in src/views/ (typed — see below)
description?: string; // Shown during view discovery
hosts?: ("apps-sdk" | "mcp-app")[]; // Defaults to both
prefersBorder?: boolean; // Removes the default iframe border when false
domain?: string; // Custom view domain
csp?: {
resourceDomains?: string[]; // Static assets (images, fonts, scripts)
connectDomains?: string[]; // Fetch / XHR targets
frameDomains?: string[]; // Iframe embed origins
redirectDomains?: string[]; // Allowed external redirects (skips safe-link modal)
baseUriDomains?: string[]; // `<base href>` origins (mcp-apps only)
};
_meta?: Record<string, unknown>;
};
See CSP & Domains below.
securitySchemes
Declare which auth schemes this tool supports so clients can label it as public vs. requires-sign-in before invocation, and request the right OAuth scopes during sign-in.
type SecurityScheme =
| { type: "noauth" }
| { type: "oauth2"; scopes?: string[] };
- Across the array: match ANY. Each entry is an alternative. The request is authorized if any one scheme is satisfied.
- Within an
oauth2 entry’s scopes: match ALL. The validated token must carry every listed scope.
- Listing
noauth + oauth2 means “works anonymously, but auth gives more features.”
This field is client-facing metadata. Servers must still enforce auth on each call — verify extra.authInfo and check authInfo.scopes in the handler (or use a tool-call middleware). The declaration is not self-enforcing.
Examples
Protected tool:
const SEARCH_SCOPES = ["search.read"];
server.registerTool(
{
name: "search-private-docs",
description: "Search documents in the user's workspace.",
inputSchema: { q: z.string() },
securitySchemes: [{ type: "oauth2", scopes: SEARCH_SCOPES }],
},
async ({ q }, extra) => {
if (!extra.authInfo) {
return { content: "Sign in required.", isError: true };
}
const missing = SEARCH_SCOPES.filter(
(s) => !extra.authInfo!.scopes.includes(s),
);
if (missing.length > 0) {
return {
content: `Missing scope(s): ${missing.join(", ")}.`,
isError: true,
};
}
return { content: await search(q) };
},
);
Public tool:
server.registerTool(
{
name: "search-public-docs",
description: "Search public documents — no sign-in required.",
inputSchema: { q: z.string() },
securitySchemes: [{ type: "noauth" }],
},
async ({ q }) => ({ content: await search(q) }),
);
Anonymous OR authenticated (single tool, different behavior):
server.registerTool(
{
name: "search",
description: "Search docs. Authenticated users see their private workspace too.",
inputSchema: { q: z.string() },
securitySchemes: [
{ type: "noauth" },
{ type: "oauth2", scopes: ["search.read"] },
],
},
async ({ q }, extra) => {
const includePrivate = !!extra.authInfo;
return { content: await search(q, { includePrivate }) };
},
);
For the transport-level middleware that pairs with mixed-auth tools, see optionalBearerAuth.
Handler
type ToolHandler = (
input: Input,
extra: RequestHandlerExtra,
) => Promise<{
content?: string | ContentBlock | ContentBlock[]; // Text(s) for the model
structuredContent?: StructuredData; // Data for the view
_meta?: Record<string, unknown>; // Response metadata
}>;
content can be a plain string, a single ContentBlock, or an array of ContentBlocks. See the different content helpers that Skybridge provides.
The extra parameter exposes:
extra.authInfo — populated by auth middleware (e.g. authInfo.clientId, authInfo.extra.email)
extra.requestInfo — HTTP request details (headers, URL)
extra.signal — abort signal for cancellation
extra._meta — client-supplied hints injected by the host (see Client hints on _meta)
ChatGPT may inject context about the current conversation in tool calls. Skybridge types these fields on extra._meta so you can use them in the handler:
type ClientHintsMeta = {
// Requested locale (BCP-47, e.g. "en-US")
"openai/locale"?: string;
// Browser user-agent string of the ChatGPT client
"openai/userAgent"?: string;
// Coarse user location
"openai/userLocation"?: {
city?: string;
region?: string;
country?: string;
timezone?: string;
longitude?: number;
latitude?: number;
};
// Anonymized user id (for rate limiting / identification)
"openai/subject"?: string;
// Anonymized conversation id, stable within a ChatGPT session
"openai/session"?: string;
// Anonymized organization id, when the user account is part of an organization
"openai/organization"?: string;
// Stable id for the currently mounted widget instance
"openai/widgetSessionId"?: string;
};
server.registerTool(
{
name: "weather",
description: "Show weather for a location.",
inputSchema: { city: z.string().optional() },
},
async ({ city }, extra) => {
// Fall back to the user's current city when they didn't specify one.
const target =
city ?? extra._meta?.["openai/userLocation"]?.city ?? "Paris";
// Imperial for en-US, metric everywhere else.
const locale = extra._meta?.["openai/locale"] ?? "en-US";
const units = locale === "en-US" ? "imperial" : "metric";
const weather = await fetchWeather(target, units);
return {
content: `${target}: ${weather.temperature}° ${weather.conditions}`,
};
},
);
ChatGPT only. These hints are injected by the OpenAI Apps SDK runtime. MCP Apps hosts do not send them on tool calls. As a fallback, use [useUser (/api-reference/use-user) to get session context from the view.
These are hints. Never use them for authorization, and tolerate their absence.
Response Fields
Three fields, three audiences:
| Field | Purpose | Consumed by |
|---|
content | Text description | The host (shown in conversation) |
structuredContent | Typed data | Host and view (useToolInfo, useCallTool) |
_meta | Response metadata | View only (hidden from the model) |
See Data Flow: Response Fields.
viewUUID: Skybridge automatically injects a unique viewUUID into _meta for every tool with view call. This enables useViewState and createStore to persist state across re-renders in MCP Apps via localStorage. Any _meta you return is preserved alongside the injected viewUUID.
Examples
File src/views/greeting.tsx exports the React component. view.component matches its file name.
import { McpServer } from "skybridge/server";
import { z } from "zod";
const server = new McpServer({ name: "my-app", version: "0.0.1" }, {})
.registerTool(
{
name: "greet",
description: "Greet someone by name.",
inputSchema: { name: z.string() },
view: { component: "greeting", description: "A greeting card" },
},
async ({ name }) => {
return {
content: `Greeting ${name}`,
structuredContent: { message: `Hello, ${name}!` },
};
},
);
server.registerTool(
{
name: "calculate",
description: "Evaluate an arithmetic expression.",
inputSchema: { expression: z.string() },
},
async ({ expression }) => {
const value = evaluate(expression);
return { content: `Result: ${value}` };
},
);
With external API and CSP
server.registerTool(
{
name: "weather",
description: "Show weather for a location.",
inputSchema: {
city: z.string(),
},
outputSchema: {
temperature: z.number(),
conditions: z.string(),
humidity: z.number(),
},
view: {
component: "weather",
},
},
async ({ city }) => {
const weather = await fetchWeather(city);
return {
content: `Current weather in ${city}: ${weather.temperature}°, ${weather.conditions}`,
structuredContent: weather,
};
},
);
With auth context
Pull authentication info from extra (requires auth middleware via .use()):
server.registerTool(
{
name: "user-profile",
description: "Show user profile.",
inputSchema: { userId: z.string() },
view: { component: "user-profile" },
},
async ({ userId }, extra) => {
const email = extra.authInfo?.extra?.email;
const user = await getUser(userId);
if (!user) {
return {
content: "Error: User not found",
isError: true,
};
}
return {
content: `Showing profile for ${user.name}`,
structuredContent: { ...user, viewedBy: email },
};
},
);
Content helpers
skybridge/server exports helpers for building ContentBlocks:
import { audio, embeddedResource, image, resourceLink, text } from "skybridge/server";
return {
content: [
text("Here's your chart:"),
image(pngBuffer, "image/png"),
],
structuredContent: { /* … */ },
};
| Helper | Block type |
|---|
text(value, annotations?) | TextContent |
image(data, mimeType, annotations?) | ImageContent (base64-encoded) |
audio(data, mimeType, annotations?) | AudioContent (base64-encoded) |
embeddedResource(resource, annotations?) | EmbeddedResource |
resourceLink(link, annotations?) | ResourceLink |
You can still return plain ContentBlock objects — helpers are opt-in.
Define expected inputs with Zod:
inputSchema: {
query: z.string().describe("Search query"),
limit: z.number().optional().default(10),
sortBy: z.enum(["price", "rating", "distance"]),
filters: z.object({
minPrice: z.number().optional(),
maxPrice: z.number().optional(),
}).optional(),
amenities: z.array(z.string()).optional(),
}
Output Schema
Optional type hints:
outputSchema: {
results: z.array(z.object({
id: z.string(),
name: z.string(),
price: z.number(),
})),
totalCount: z.number(),
}
CSP & Domains
Views run in a sandboxed iframe with strict Content Security Policy. If your view needs to:
- Fetch data from external APIs → add to
connectDomains
- Load images, fonts, or scripts from CDNs → add to
resourceDomains
- Embed external iframes → add to
frameDomains
- Redirect to external sites → add to
redirectDomains
Skybridge automatically includes your server’s domain in the CSP. You only need to configure additional external domains.
Configuration
server.registerTool(
{
name: "flight-search",
description: "Search for flights",
inputSchema: { /* … */ },
view: {
component: "flight-search",
prefersBorder: false,
csp: {
connectDomains: ["https://api.flights.com"],
resourceDomains: ["https://cdn.flights.com", "https://fonts.googleapis.com"],
frameDomains: ["https://maps.google.com"],
redirectDomains: ["https://booking.flights.com"],
},
},
},
async (input) => { /* … */ },
);
CSP Fields
| Field | Purpose | Example |
|---|
connectDomains | Fetch/XHR targets | ["https://api.example.com"] |
resourceDomains | Static assets (images, fonts, scripts, styles) | ["https://cdn.example.com"] |
frameDomains | Iframe embed origins | ["https://youtube.com"] |
redirectDomains | External redirect destinations | ["https://checkout.example.com"] |
baseUriDomains | <base href> origins (mcp-apps only) | ["https://app.example.com"] |