---
title: registerTool
description: "Register an MCP tool — with or without a React view"
---


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

```typescript
server.registerTool(
  config: ToolConfig,
  handler: ToolHandler,
): McpServer
```

## Config

```typescript
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[] };
```

<Note>
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`](/api-reference/file-ref) doc.
</Note>

### `view`

Binds the tool to a React view.

```typescript
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](#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.

```typescript
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."

<Warning>
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.
</Warning>

#### Examples

Protected tool:

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

```typescript
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):

```typescript
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`](/api-reference/mcp-server#optionalbearerauth).

## Handler

```typescript
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 `ContentBlock`s. See the different [content helpers](#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`](#client-hints-on-meta))

### 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:

```typescript
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;
};
```

```typescript
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}`,
    };
  },
);
```

<Info>
**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.
</Info>

<Warning>
These are **hints**. Never use them for authorization, and tolerate their absence.
</Warning>

## 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](/concepts/data-flow#response-fields-explained).

<Info>
**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`.
</Info>

## Examples

### Tool with a view

File `src/views/greeting.tsx` exports the React component. `view.component` matches its file name.

```typescript
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}!` },
      };
    },
  );
```

### Tool without view

```typescript
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

```typescript
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()`](/api-reference/mcp-server#use)):

```typescript
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 `ContentBlock`s:

```typescript
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.

## Input Schema

Define expected inputs with Zod:

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

```typescript
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`

<Note>
Skybridge automatically includes your server's domain in the CSP. You only need to configure additional external domains.
</Note>

### Configuration

```typescript
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"]` |

## Related

- [McpServer](/api-reference/mcp-server) - Server class
- [useToolInfo](/api-reference/use-tool-info) - Access view data
- [Data Flow](/concepts/data-flow) - The communication loop
- [Type Safety](/concepts/type-safety) - End-to-end inference
