Guide · TypeScript

MCP server satisfies operator

TypeScript's satisfies operator (introduced in 4.9) validates that a value conforms to a type without widening the value's inferred type. For MCP server tool definitions, this means the compiler rejects malformed tools while still letting you extract tool names as literal string types — not just string. Without satisfies, you must choose between type safety (add an annotation, lose literals) or literal preservation (use as const, lose constraint validation).

TL;DR

Write const tools = [...] satisfies ToolDefinition[]. The compiler validates every tool against ToolDefinition, and typeof tools[number]['name'] still infers "read_file" | "write_file" — not string. This gives you the best of both worlds: constraint enforcement at define-time and literal types for downstream mapped-type dispatch. Requires TypeScript 4.9 or later, which ships with Node.js 20+ projects using a modern tsconfig.

The as const vs explicit annotation dilemma

When you define an MCP tool array, you face a classic TypeScript tradeoff. Consider what each approach infers for the name property:

// Approach 1: No annotation, no as const
// TypeScript widens name to string — literals are lost
const tools1 = [
  { name: "read_file", description: "Read a file" /* ... */ },
  { name: "write_file", description: "Write a file" /* ... */ },
];
// typeof tools1[number]['name'] = string  ❌ useless for dispatch

// Approach 2: as const — preserves literals, but no constraint validation
const tools2 = [
  { name: "read_file", description: "Read a file" /* ... */ },
  { name: "write_file", /* MISSING description — no error! */ },
] as const;
// typeof tools2[number]['name'] = "read_file" | "write_file"  ✓
// But typos in inputSchema.type or missing required fields: silently ignored ❌

// Approach 3: Explicit annotation — validates structure, but widens name
const tools3: ToolDefinition[] = [
  { name: "read_file", description: "Read a file" /* ... */ },
  { name: "write_file", description: "Write a file" /* ... */ },
];
// typeof tools3[number]['name'] = string  ❌ widened away by annotation

// Approach 4: satisfies — validates AND preserves literals
const tools4 = [
  { name: "read_file", description: "Read a file" /* ... */ },
  { name: "write_file", description: "Write a file" /* ... */ },
] satisfies ToolDefinition[];
// typeof tools4[number]['name'] = "read_file" | "write_file"  ✓
// Missing fields or wrong types: compile error  ✓

The as const approach is commonly used in MCP server examples because it preserves literal types, which are required for the mapped type dispatch pattern. But as const does not validate structure — a tool with a misspelled inputSchema.type of "object2" or a missing description field compiles without error. satisfies closes this gap.

What satisfies does

The satisfies operator performs a type compatibility check at the point of expression without changing the inferred type of the expression. Contrast with a type annotation (: T), which both checks and widens the type to T:

type Color = "red" | "green" | "blue";

// Type annotation — widened
const c1: Color = "red";
// typeof c1 = Color (widened from "red" to "red" | "green" | "blue")

// satisfies — checked but not widened
const c2 = "red" satisfies Color;
// typeof c2 = "red"  (literal preserved)

// For arrays, annotation widens element types:
const arr1: Color[] = ["red", "green"];
// typeof arr1[number] = Color

// satisfies preserves tuple element literal types:
const arr2 = ["red", "green"] satisfies Color[];
// typeof arr2[number] = "red" | "green"  (not "blue" — blue is absent)

Applied to an MCP tools array, satisfies ToolDefinition[] checks that every element matches ToolDefinition — catching missing required properties, wrong field types, and invalid enum values — while typeof tools[number]['name'] still resolves to the literal union of the names you actually wrote.

Complete example: ToolDefinition type and the tools array

Define a ToolDefinition type that mirrors the MCP tool descriptor shape, then apply satisfies:

// tool-definition.ts

// JSON Schema object descriptor for the inputSchema field
interface JsonSchemaObject {
  type: "object";
  properties: Record<string, {
    type: "string" | "number" | "boolean" | "array" | "object";
    description?: string;
    enum?: readonly string[];
  }>;
  required?: readonly string[];
  additionalProperties?: boolean;
}

// The shape every tool definition must satisfy
export interface ToolDefinition {
  name: string;
  description: string;
  inputSchema: JsonSchemaObject;
}

// tools.ts
import type { ToolDefinition } from "./tool-definition.js";

export const tools = [
  {
    name: "read_file",
    description: "Read a file from the filesystem and return its contents",
    inputSchema: {
      type: "object",
      properties: {
        path: { type: "string", description: "Absolute path to the file" },
        encoding: {
          type: "string",
          enum: ["utf8", "base64"] as const,
          description: "Output encoding — defaults to utf8",
        },
      },
      required: ["path"] as const,
    },
  },
  {
    name: "write_file",
    description: "Write content to a file, optionally creating parent directories",
    inputSchema: {
      type: "object",
      properties: {
        path: { type: "string", description: "Absolute path to write" },
        content: { type: "string", description: "Content to write" },
        create_dirs: { type: "boolean", description: "Create parent dirs if missing" },
      },
      required: ["path", "content"] as const,
    },
  },
  {
    name: "search",
    description: "Search the indexed content store for matching documents",
    inputSchema: {
      type: "object",
      properties: {
        query: { type: "string", description: "Search query string" },
        limit: { type: "number", description: "Maximum number of results (1–100)" },
      },
      required: ["query"] as const,
    },
  },
] satisfies ToolDefinition[];

// Now add a broken tool to see the compile error:
// {
//   name: "broken_tool",
//   // MISSING description — TypeScript error:
//   // Property 'description' is missing in type '{ name: string; inputSchema: ... }'
//   // but required in type 'ToolDefinition'
//   inputSchema: { type: "object2", properties: {} },
//   // Also errors: type "object2" is not assignable to type "object"
// }

The compiler catches both the missing description field and the invalid type: "object2" value before the code runs. With as const alone, these would be silent.

Extracting the literal tool name union

After applying satisfies, the full literal union is available for type-level dispatch:

import { tools } from "./tools.js";

// Extract the literal union of all tool names
type ToolName = typeof tools[number]["name"];
// = "read_file" | "write_file" | "search"

// Runtime guard that narrows string to ToolName
const toolNameSet = new Set<string>(tools.map((t) => t.name));
function isToolName(name: string): name is ToolName {
  return toolNameSet.has(name);
}

// Example: build a handler type keyed on the literal union
type ToolHandlerMap = {
  [K in ToolName]: (args: Record<string, unknown>) => Promise<{ content: Array<{ type: string; text: string }> }>;
};

// This would fail to compile if tools used `: ToolDefinition[]` annotation
// because ToolName would be `string`, making the mapped type useless.

If the tools array were annotated as : ToolDefinition[] instead of using satisfies, then typeof tools[number]['name'] would resolve to string — and type ToolName = string makes the mapped type { [K in string]: ... }, which is just Record<string, ...>. The whole type-safe dispatch system collapses. satisfies prevents this.

Building a type-safe dispatch table

With the literal union extracted, wire it into the setRequestHandler dispatch alongside Zod validation:

import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
import { tools, ToolName, isToolName } from "./tools.js";

const server = new Server({ name: "satisfies-demo", version: "1.0.0" });

// Zod schemas keyed by ToolName literal — also benefits from satisfies
const toolSchemas = {
  read_file: z.object({
    path: z.string().min(1),
    encoding: z.enum(["utf8", "base64"]).default("utf8"),
  }),
  write_file: z.object({
    path: z.string().min(1),
    content: z.string(),
    create_dirs: z.boolean().default(false),
  }),
  search: z.object({
    query: z.string().min(1),
    limit: z.number().int().min(1).max(100).default(10),
  }),
} satisfies { [K in ToolName]: z.ZodTypeAny };
// satisfies here ensures toolSchemas has exactly the same keys as ToolName —
// no missing keys, no extra keys that drift from the tools array.

server.setRequestHandler(ListToolsRequestSchema, async () => ({
  tools: tools.map(({ name, description, inputSchema }) => ({
    name, description, inputSchema,
  })),
}));

server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const { name, arguments: rawArgs } = request.params;

  if (!isToolName(name)) {
    return {
      isError: true,
      content: [{ type: "text", text: `Unknown tool: ${name}` }],
    };
  }

  const schema = toolSchemas[name]; // indexed by ToolName — type-safe
  const parsed = schema.safeParse(rawArgs ?? {});

  if (!parsed.success) {
    return {
      isError: true,
      content: [{
        type: "text",
        text: JSON.stringify({
          error: "invalid_arguments",
          tool: name,
          issues: parsed.error.issues.map((i) => ({
            path: i.path.join(".") || "(root)",
            message: i.message,
          })),
        }),
      }],
    };
  }

  // Dispatch to the appropriate handler
  switch (name) {
    case "read_file":
      return handleReadFile(parsed.data);
    case "write_file":
      return handleWriteFile(parsed.data);
    case "search":
      return handleSearch(parsed.data);
  }
});

Note the second satisfies on toolSchemas: satisfies { [K in ToolName]: z.ZodTypeAny }. This ensures the schema map has exactly the right keys — if you add a tool to the tools array and forget to add its schema here, the compiler flags it immediately. The satisfies operator on both objects creates a self-consistent system.

Catching errors at define-time

The primary value of satisfies ToolDefinition[] is that structural mistakes in your tool definitions surface at compile time rather than at LLM call time. Common errors it catches:

const badTools = [
  {
    name: "broken_one",
    // Error: Property 'description' is missing in type '...' but required in 'ToolDefinition'
    inputSchema: {
      type: "object",
      properties: { path: { type: "string" } },
      required: ["path"],
    },
  },
  {
    name: "broken_two",
    description: "Has wrong inputSchema type field",
    inputSchema: {
      type: "OBJECT",  // Error: Type '"OBJECT"' is not assignable to type '"object"'
      properties: { query: { type: "string" } },
    },
  },
  {
    name: "broken_three",
    description: "Has a property with unknown type",
    inputSchema: {
      type: "object",
      properties: {
        count: { type: "integer" },  // Error: '"integer"' not assignable to '"string" | "number" | ...'
      },
    },
  },
] satisfies ToolDefinition[];  // All three errors reported here, at definition

Without satisfies, all three of these errors would silently compile and produce malformed tool descriptors sent to the LLM. The LLM might still call the tool (ignoring the malformed schema) or might fail to invoke it correctly — and the bug would only surface in a live agent session, not in CI.

Comparison: as, as const, annotation, and satisfies

Approach Preserves literal names Validates structure Widening risk Recommended for MCP
as ToolDefinition[] No — widens to string No — cast bypasses checks High — hides errors Never
as const Yes No — no constraint check None Acceptable if no ToolDefinition type
: ToolDefinition[] No — widens to string Yes Medium — loses literals Not for dispatch; OK for export
satisfies ToolDefinition[] Yes Yes None Yes — best of both

For MCP tool definitions that feed into a mapped-type dispatch system, satisfies ToolDefinition[] is the only approach that provides both benefits simultaneously. The constraint check catches authoring errors; the literal preservation enables the type-safe handler registry described in the mapped types guide.

Related questions

What TypeScript version do I need for satisfies?

TypeScript 4.9, released November 2022. Check your version with npx tsc --version. If you are using Node.js 18 or later with a project scaffolded in 2023 or after, you almost certainly have TypeScript 5.x and satisfies is available. If you are on an older TypeScript, upgrade: npm install --save-dev typescript@latest. There are no runtime implications — satisfies is erased entirely by the compiler and does not appear in emitted JavaScript.

Does satisfies work with Zod schemas?

Yes. You can use satisfies on a Zod schema object or on a record of schemas. For example, const schemas = { read_file: z.object({...}), write_file: z.object({...}) } satisfies { [K in ToolName]: z.ZodTypeAny } ensures the schemas object has exactly the keys in ToolName — no missing tools, no extra keys that have drifted from the tools array. The Zod schemas themselves remain narrowly typed (e.g., ZodObject<...> rather than just ZodTypeAny), which means TypeScript can still infer the exact schema type when you access schemas["read_file"].

Does satisfies catch runtime errors?

No — satisfies is a compile-time check only. It validates the static structure of your tool definitions against ToolDefinition, but it does not run any code at runtime. If your inputSchema properties object references a variable whose value is only known at runtime (e.g., a config-driven enum list), satisfies checks the static type of that variable, not its runtime value. For runtime validation of tool arguments, use Zod type guards in your handler — satisfies and Zod solve different problems and complement each other.

Can I mix satisfies and as const?

Yes, and it is sometimes useful. Write const tools = [...] as const satisfies ToolDefinition[]. The as const is applied first (deepest readonly narrowing, all string literals preserved), then satisfies checks the resulting type against ToolDefinition[]. The result has the deepest possible literal types and full constraint checking. This is particularly useful when your ToolDefinition uses readonly arrays for required and enum fields, since as const makes those fields readonly automatically. Without as const, you may need explicit as const assertions on the inner arrays.

Further reading