The MCP Server: Tools, Results, and Deployment
On this page
Lesson rewritten on November 3, 2026 based on the Build an MCP server page from the OpenAI Plugins documentation and the MCP TypeScript SDK code (1.32.2). The previous lesson described “actions” (
defineAction,ActionError) from a non-existent SDK; this role now belongs to MCP tools. Sources: Build an MCP server · MCP TypeScript SDK.
The server, its identity, and its instructions
Create an MCP server with a stable name and version. During initialization, it can also return an instructions field that ChatGPT and Codex read alongside tool metadata. Reserve this field for guidelines that apply to all tools—such as a mandatory call order or a shared rate limit—and keep the core content within the first 512 characters. Do not copy-paste tool descriptions here, and do not try to change the model’s personality.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
const server = new McpServer(
{ name: "acme-projects", version: "1.0.0" },
{
instructions:
"Before modifying a project, call get_project to confirm its ID and status.",
}
);
One tool per action
Create one tool for each distinct action: list_projects, get_project, update_project, rather than a single catch-all tool with numerous modes. Each tool combines an action-oriented name, a readable title, a description explaining when to use it, an explicit input schema, an output schema if it returns structured data, precise annotations, and a handler that authorizes the request before acting. The model relies on this metadata to decide whether and how to call the tool—treat it as part of the user experience.
import { z } from "zod";
const project = z.object({ id: z.string(), name: z.string(), status: z.enum(["active", "archived"]) });
server.registerTool(
"get_project",
{
title: "View a project",
description:
"Use when the user wants details about a specific project in their Acme workspace. " +
"Requires the ID returned by list_projects.",
inputSchema: { id: z.string().min(1) },
outputSchema: { project: project },
annotations: { readOnlyHint: true, openWorldHint: false, destructiveHint: false },
},
async ({ id }, extra) => {
const user = await authenticate(extra); // Your access control (Lesson 8)
const found = await loadProject(user, id); // Your data access
if (!found) {
return {
isError: true,
content: [{ type: "text", text: `No project ${id} is accessible with this account.` }],
};
}
return {
structuredContent: { project: found },
content: [{ type: "text", text: `Project "${found.name}" (${found.status}).` }],
};
}
);
authenticate and loadProject are your own functions. The critical point is their placement: authorization happens on the server, with every request. Never rely on the model to decide whether a user has permission to access data.
Useful results without an interface
A tool result can carry three things:
structuredContent: concise data that the model can inspect and reuse in subsequent calls;content: text or other MCP content that helps it answer;_meta: data intended for the client, hidden from the model.
Return enough information for the model to finish the job without a visual component, using stable IDs that subsequent tools can reuse. Two SDK behaviors are worth noting. A tool declaring an outputSchema must return compliant structuredContent in all successful results; otherwise, the SDK throws a validation error. A business error—such as a missing project or a denied action—is signaled by a result with isError: true accompanied by explanatory text that the model can relay to the user; this type of result bypasses output schema validation.
Never include secrets, access tokens, or unnecessary personal data in a result, and do not treat _meta as a safe hiding spot: this field is hidden from the model, but it is not protected.
Annotations and elicitation
Annotations must reflect the tool’s actual behavior: set readOnlyHint to true only if it cannot modify anything, destructiveHint to true if it can cause an irreversible or hard-to-undo effect, and openWorldHint to true if it touches the public internet or open external entities. They help ChatGPT and Codex choose the right confirmation behavior; they do not replace authorization, validation, or confirmation logic on your server.
When the server needs structured information missing from the call—such as a date or a choice between two options—it can request it via elicitation, an MCP protocol mechanism. Limit this to what the user can reasonably provide; never use it to collect secrets or bypass normal authentication.
Multiple accounts and company knowledge
Two optional compatibilities improve integration. To help users connecting multiple accounts, expose a profile tool that is authenticated, read-only, and marked _meta["openai/profile"]: true. OpenAI uses this to identify connected accounts and help users distinguish between them. The profile is derived from the request’s validated credentials, and each call remains limited to those credentials.
ChatGPT’s company knowledge feature can use your server’s read-only tools. To make a plugin eligible as a source, implement the standard input schemas for search and fetch tools, mark other read-only tools with readOnlyHint: true, and return absolute, user-openable URLs for sources the model must cite.
Deploying the endpoint
Public submission requires a server at a stable, publicly reachable HTTPS address. This endpoint must support streamable HTTP transport, respond at a stable URL (typically ending in /mcp), meet the latency and availability demands of your workflows, reach your services and data, preserve authentication and authorization boundaries, and produce logs and metrics for initialization and tool call failures.
OpenAI’s Secure MCP Tunnel allows you to connect a private server to ChatGPT, but it does not meet public submission requirements, nor does a temporary tunnel or local address. If the server must remain private, place a public HTTPS proxy in front of it: mTLS managed by OpenAI authenticates ChatGPT as the client, OAuth 2.1 authenticates your users, and an IP allowlist—including ranges published by OpenAI for ChatGPT connectors, updated automatically—can be added, but never replaces authentication.
Before going live: inject credentials via your host’s secret manager, configure the authorization server and redirects, set timeouts and rate limits for expensive or externally visible tools, remove debug responses and unnecessary personal data, and verify logs contain no tokens or sensitive results. After deployment, replay MCP Inspector against the production address.
Evolving without breaking
Keep published tool names and schemas backward compatible: add fields or tools, but do not break existing contracts. If metadata changes, refresh the connection and replay your evaluation suite before submitting. For an interface, change the resource ID when HTML, JavaScript, or CSS evolves in a way that could break a cached component.
Security reminders
- All tool input is untrusted: validate it and enforce authorization on the server
- Require confirmation for high-impact writes
- Keep secrets and sensitive data out of metadata and results
- Log enough to investigate, but exclude credentials and unnecessary personal data
- Rate-limit expensive or externally visible actions
Key takeaways
- A named, versioned server with short, cross-cutting
instructions - One tool per action, with a title, intent description, schemas, annotations, and authorization in the handler
structuredContentcompliant with the output schema,isError: truefor business errors, nothing secret in_meta- Publication: stable, public HTTPS via streamable HTTP; the Secure MCP Tunnel is insufficient
- Backward-compatible contracts and versioned resource IDs