Skip to content

Adding MCP Prompts

Learn how to create custom MCP (Model Context Protocol) prompts to provide reusable prompt templates for Claude.

Overview

MCP prompts are predefined message templates that Claude can use to structure conversations. Unlike tools (which perform actions) and resources (which provide data), prompts are templates that help Claude initiate or structure interactions with users.

Use cases:

  • Onboarding flows and welcome messages
  • Role-specific system prompts
  • User context personalization
  • Consistent conversation starters
  • Template-based interactions

Prompt Architecture

Prompt Components

src/mcp/prompts/
├── index.ts                # Prompt registry
├── greeting.ts             # Example: Basic prompt
├── user-context.ts         # Example: Context-aware prompt
├── greeting.test.ts        # Example prompt tests
└── user-context.test.ts

Prompt workflow:

  1. Client calls prompts/list → Server returns available prompts
  2. Client calls prompts/get with prompt name and arguments
  3. Server generates prompt messages from template
  4. Client uses messages to initiate conversation

Prompt Structure

typescript
export function registerMyPrompt(server: McpServer): void {
  server.registerPrompt(
    "prompt_name",               // Prompt identifier
    {
      description: "...",        // Human-readable description
      argsSchema: {...}          // Zod schema for arguments
    },
    (args, extra) => {           // Prompt handler function
      // Generate messages from template
      return {
        messages: [
          {
            role: "assistant",   // Message appears as assistant output in context
            content: {
              type: "text",
              text: "..."
            }
          }
        ]
      };
    }
  );
}

Note: MCP prompts provide conversation context, not direct output. The returned messages are injected into the conversation history, and Claude then responds based on that context.

Creating a Prompt

Step 1: Create Prompt File

Create src/mcp/prompts/my_prompt.ts:

typescript
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { logger } from "../../services/logger.js";

/**
 * My Custom Prompt
 *
 * Describe what this prompt does and when to use it.
 */

const myPromptArgsSchema = {
  // Define prompt arguments using Zod
  userName: z.string().optional().describe("User's name for personalization"),
  context: z.string().optional().describe("Additional context"),
};

export function registerMyPrompt(server: McpServer): void {
  server.registerPrompt(
    "my-prompt",
    {
      description: "Description of what the prompt generates",
      argsSchema: myPromptArgsSchema,
    },
    (args, extra) => {
      try {
        // Extract arguments
        const userName = args.userName ?? "User";
        const context = args.context ?? "";

        logger.info("My prompt invoked", {
          hasName: !!args.userName,
          category: "mcp-prompt",
        });

        // Generate prompt text
        const promptText = `Hello ${userName}! ${context}`;

        return {
          messages: [
            {
              role: "assistant",
              content: {
                type: "text",
                text: promptText,
              },
            },
          ],
        };
      } catch (error) {
        logger.error("My prompt error", {
          error: error instanceof Error ? error.message : String(error),
          category: "mcp-prompt",
        });

        return {
          messages: [
            {
              role: "assistant",
              content: {
                type: "text",
                text: `Error generating prompt: ${error instanceof Error ? error.message : "Unknown error"}`,
              },
            },
          ],
        };
      }
    }
  );
}

Step 2: Define Arguments Schema

Use Zod to define and validate arguments:

typescript
const myPromptArgsSchema = {
  // Required string
  topic: z.string().describe("The topic to discuss"),

  // Optional string with default
  tone: z
    .enum(["formal", "casual", "technical"])
    .optional()
    .describe("Conversation tone (default: casual)"),

  // Optional number with constraints
  detailLevel: z
    .number()
    .min(1)
    .max(5)
    .optional()
    .describe("Level of detail from 1-5 (default: 3)"),

  // Optional boolean
  includeExamples: z
    .boolean()
    .optional()
    .describe("Whether to include examples (default: true)"),

  // Optional array
  tags: z
    .array(z.string())
    .optional()
    .describe("Relevant tags for context"),
};

Step 3: Implement Prompt Logic

typescript
(args, extra) => {
  try {
    // 1. Extract and validate arguments
    const topic = args.topic;
    const tone = args.tone ?? "casual";
    const detailLevel = args.detailLevel ?? 3;
    const includeExamples = args.includeExamples ?? true;

    // 2. Access user context if needed (see User Context section)
    const userContext = extra?._meta?.user as UserContext | undefined;

    // 3. Build prompt text based on arguments
    let promptText = `Let's discuss ${topic}`;

    if (tone === "formal") {
      promptText = `I would like to formally discuss the topic of ${topic}`;
    }

    if (detailLevel > 3) {
      promptText += " in depth";
    }

    if (includeExamples) {
      promptText += ", with practical examples";
    }

    // 4. Log for observability
    logger.info("Prompt generated", {
      topic,
      tone,
      detailLevel,
      category: "mcp-prompt",
    });

    // 5. Return message array
    return {
      messages: [
        {
          role: "assistant",
          content: {
            type: "text",
            text: promptText,
          },
        },
      ],
    };
  } catch (error) {
    // Always handle errors gracefully
    logger.error("Prompt generation failed", {
      error: error instanceof Error ? error.message : String(error),
      category: "mcp-prompt",
    });

    return {
      messages: [
        {
          role: "assistant",
          content: {
            type: "text",
            text: `Error: ${error instanceof Error ? error.message : "Unknown error"}`,
          },
        },
      ],
    };
  }
}

Step 4: Register Prompt

Add to src/mcp/prompts/index.ts:

typescript
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { registerGreetingPrompt } from "./greeting.js";
import { registerUserContextPrompt } from "./user-context.js";
import { registerMyPrompt } from "./my_prompt.js"; // Add import

export function registerPrompts(server: McpServer): void {
  registerGreetingPrompt(server);
  registerUserContextPrompt(server);
  registerMyPrompt(server); // Add registration
}

Step 5: Test the Prompt

Create src/mcp/prompts/my_prompt.test.ts:

typescript
import { describe, it, expect, beforeEach, vi } from "vitest";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { registerMyPrompt } from "./my_prompt.js";
import type { UserContext } from "../../types/index.js";

describe("My Prompt", () => {
  let server: McpServer;

  beforeEach(() => {
    server = new McpServer({
      name: "test-server",
      version: "1.0.0",
    });

    registerMyPrompt(server);
  });

  it("should register prompt with correct name", () => {
    const prompts = Array.from(server.getPrompts());
    expect(prompts).toContainEqual(
      expect.objectContaining({
        name: "my-prompt",
      })
    );
  });

  it("should generate prompt with default arguments", async () => {
    const handler = server.getPromptHandler("my-prompt");
    expect(handler).toBeDefined();

    const result = await handler!(
      { topic: "TypeScript" },
      { _meta: {} }
    );

    expect(result.messages).toHaveLength(1);
    expect(result.messages[0].role).toBe("user");
    expect(result.messages[0].content.text).toContain("TypeScript");
  });

  it("should handle custom arguments", async () => {
    const handler = server.getPromptHandler("my-prompt");

    const result = await handler!(
      {
        topic: "Testing",
        tone: "formal",
        detailLevel: 5,
        includeExamples: true,
      },
      { _meta: {} }
    );

    expect(result.messages[0].content.text).toContain("Testing");
    expect(result.messages[0].content.text).toContain("examples");
  });

  it("should handle errors gracefully", async () => {
    const handler = server.getPromptHandler("my-prompt");

    // Pass invalid arguments
    const result = await handler!(
      { topic: null } as any,
      { _meta: {} }
    );

    expect(result.messages[0].content.text).toContain("Error");
  });
});

Accessing User Context

Prompts can access authenticated user information from JWT claims:

typescript
import type { UserContext } from "../../types/index.js";

export function registerPersonalizedPrompt(server: McpServer): void {
  server.registerPrompt(
    "personalized",
    {
      description: "Generate personalized prompt using user context",
      argsSchema: {},
    },
    (args, extra) => {
      // Extract user context from metadata
      const userContext = extra?._meta?.user as UserContext | undefined;

      if (!userContext) {
        return {
          messages: [
            {
              role: "assistant",
              content: {
                type: "text",
                text: "Error: User context not available",
              },
            },
          ],
        };
      }

      // Use user information
      const userName = userContext.name ?? userContext.email ?? "User";
      const userId = userContext.sub;
      const userGroups = userContext.groups ?? [];

      const promptText = `Welcome back, ${userName}!
Your ID: ${userId}
Your groups: ${userGroups.join(", ") || "none"}

How can I assist you today?`;

      return {
        messages: [
          {
            role: "assistant",
            content: {
              type: "text",
              text: promptText,
            },
          },
        ],
      };
    }
  );
}

Available user context fields:

  • sub - User ID (subject claim)
  • email - User email address
  • name - User display name
  • groups - User group memberships (array)

When user context is unavailable:

  • AUTH_REQUIRED=false - No user context available
  • Invalid or missing JWT - User context will be undefined

Multi-Message Prompts

Prompts can return multiple messages to set up context:

typescript
return {
  messages: [
    {
      role: "assistant",
      content: {
        type: "text",
        text: "I'm ready to help with your task.",
      },
    },
    {
      role: "user",
      content: {
        type: "text",
        text: "Please analyze this data set...",
      },
    },
  ],
};

Message roles:

  • "assistant" - Message appears as prior assistant output in conversation context
  • "user" - Message appears as prior user input in conversation context

Use cases:

  • Assistant greeting + follow-up context
  • Multi-turn conversation starters
  • Role-playing scenarios
  • Setting up conversation flow

Advanced Patterns

Dynamic Content Generation

Generate prompt content based on complex logic:

typescript
export function registerAnalysisPrompt(server: McpServer): void {
  server.registerPrompt(
    "analysis-starter",
    {
      description: "Generate analysis prompt based on data type",
      argsSchema: {
        dataType: z.enum(["time-series", "categorical", "text", "image"]),
        goals: z.array(z.string()).optional(),
      },
    },
    (args) => {
      const templates = {
        "time-series": "Analyze temporal patterns, trends, and seasonality in",
        categorical: "Examine distribution, frequencies, and relationships in",
        text: "Perform sentiment analysis and topic modeling on",
        image: "Detect objects, patterns, and visual features in",
      };

      const analysis = templates[args.dataType];
      const goals = args.goals?.join(", ") ?? "general insights";

      const promptText = `${analysis} the provided data.

Focus areas: ${goals}

Please provide:
1. Key observations
2. Statistical summary
3. Actionable recommendations`;

      return {
        messages: [
          {
            role: "assistant",
            content: { type: "text", text: promptText },
          },
        ],
      };
    }
  );
}

Conditional Prompts

Adapt prompts based on user role or context:

typescript
export function registerRoleBasedPrompt(server: McpServer): void {
  server.registerPrompt(
    "role-based",
    {
      description: "Generate role-specific prompts",
      argsSchema: {},
    },
    (args, extra) => {
      const userContext = extra?._meta?.user as UserContext | undefined;
      const groups = userContext?.groups ?? [];

      let promptText = "Welcome! ";

      if (groups.includes("admin")) {
        promptText += "As an admin, you have full access to system management.";
      } else if (groups.includes("developer")) {
        promptText += "As a developer, you can access development tools and APIs.";
      } else {
        promptText += "You have standard user access.";
      }

      return {
        messages: [
          {
            role: "assistant",
            content: { type: "text", text: promptText },
          },
        ],
      };
    }
  );
}

Template Composition

Build complex prompts from reusable components:

typescript
const buildIntroduction = (name: string) =>
  `Hello ${name}! I'm here to assist you.`;

const buildInstructions = (task: string) =>
  `To complete "${task}", please follow these steps:`;

const buildConclusion = () =>
  `Let me know if you need any clarification!`;

export function registerComposedPrompt(server: McpServer): void {
  server.registerPrompt(
    "composed",
    {
      description: "Composed prompt from multiple templates",
      argsSchema: {
        name: z.string(),
        task: z.string(),
        steps: z.array(z.string()),
      },
    },
    (args) => {
      const intro = buildIntroduction(args.name);
      const instructions = buildInstructions(args.task);
      const stepsList = args.steps.map((s, i) => `${i + 1}. ${s}`).join("\n");
      const conclusion = buildConclusion();

      const promptText = `${intro}

${instructions}

${stepsList}

${conclusion}`;

      return {
        messages: [
          {
            role: "assistant",
            content: { type: "text", text: promptText },
          },
        ],
      };
    }
  );
}

Best Practices

Naming Conventions

Prompt names:

  • Use kebab-case: user-onboarding, code-review-start
  • Be descriptive: technical-docs-template not just template
  • Include category for organization: support-ticket-triage, support-escalation

File names:

  • Match prompt name: user-onboarding.ts for user-onboarding prompt
  • Include tests: user-onboarding.test.ts
  • Group related prompts in subdirectories if needed

Description Guidelines

Write clear, actionable descriptions:

Good:

typescript
description: "Generate a welcome message for new users with onboarding steps and helpful resources"

Bad:

typescript
description: "Welcome prompt" // Too vague

Include:

  • What the prompt generates
  • When to use it
  • What arguments affect the output

Argument Schema Design

Make arguments intuitive:

typescript
// Good: Clear argument names and descriptions
const schema = {
  userLevel: z.enum(["beginner", "intermediate", "expert"])
    .describe("User's expertise level for appropriate content"),
  includeCode: z.boolean()
    .optional()
    .describe("Whether to include code examples (default: true)"),
};

// Bad: Unclear names
const schema = {
  level: z.number(), // What scale? What does the number mean?
  flag: z.boolean(), // Which flag? What does it control?
};

Provide sensible defaults:

typescript
const tone = args.tone ?? "friendly";
const verbosity = args.verbosity ?? 3; // Medium detail
const includeExamples = args.includeExamples ?? true;

Error Handling

Always handle errors gracefully:

typescript
(args, extra) => {
  try {
    // Validate critical arguments
    if (!args.requiredField) {
      throw new Error("requiredField is required");
    }

    // Generate prompt
    const promptText = generatePrompt(args);

    return {
      messages: [
        {
          role: "assistant",
          content: { type: "text", text: promptText },
        },
      ],
    };
  } catch (error) {
    // Log error for debugging
    logger.error("Prompt generation failed", {
      error: error instanceof Error ? error.message : String(error),
      promptName: "my-prompt",
      category: "mcp-prompt",
    });

    // Return user-friendly error message
    return {
      messages: [
        {
          role: "assistant",
          content: {
            type: "text",
            text: `Unable to generate prompt: ${error instanceof Error ? error.message : "Unknown error"}`,
          },
        },
      ],
    };
  }
}

Logging

Use structured logging for observability:

typescript
// Log prompt invocation
logger.info("Prompt invoked", {
  promptName: "my-prompt",
  argumentCount: Object.keys(args).length,
  hasUserContext: !!extra?._meta?.user,
  category: "mcp-prompt",
});

// Log generation details
logger.debug("Prompt generated", {
  promptName: "my-prompt",
  messageCount: messages.length,
  totalLength: messages.reduce((sum, m) => sum + m.content.text.length, 0),
  category: "mcp-prompt",
});

// Log errors with context
logger.error("Prompt error", {
  promptName: "my-prompt",
  error: error.message,
  args: JSON.stringify(args),
  category: "mcp-prompt",
});

Testing

Test all aspects of prompt behavior:

typescript
describe("My Prompt", () => {
  // Test registration
  it("should register with correct metadata", () => {
    const prompts = Array.from(server.getPrompts());
    expect(prompts).toContainEqual(
      expect.objectContaining({
        name: "my-prompt",
        description: expect.stringContaining("..."),
      })
    );
  });

  // Test default behavior
  it("should generate prompt with defaults", async () => {
    const result = await handler!({}, { _meta: {} });
    expect(result.messages).toHaveLength(1);
    expect(result.messages[0].content.text).toBeTruthy();
  });

  // Test argument handling
  it("should handle all argument combinations", async () => {
    const testCases = [
      { args: { tone: "formal" }, expected: "formal language" },
      { args: { tone: "casual" }, expected: "casual language" },
    ];

    for (const testCase of testCases) {
      const result = await handler!(testCase.args, { _meta: {} });
      expect(result.messages[0].content.text).toContain(testCase.expected);
    }
  });

  // Test user context
  it("should use user context when available", async () => {
    const mockUser: UserContext = {
      sub: "user-123",
      email: "user@example.com",
      name: "Test User",
      groups: ["developers"],
    };

    const result = await handler!({}, { _meta: { user: mockUser } });
    expect(result.messages[0].content.text).toContain("Test User");
  });

  // Test error handling
  it("should handle errors gracefully", async () => {
    const result = await handler!({ invalid: "data" } as any, { _meta: {} });
    expect(result.messages[0].content.text).toContain("Error");
  });
});

Common Patterns

Onboarding Prompts

Guide new users through initial steps:

typescript
export function registerOnboardingPrompt(server: McpServer): void {
  server.registerPrompt(
    "user-onboarding",
    {
      description: "Generate step-by-step onboarding for new users",
      argsSchema: {
        userName: z.string().optional(),
      },
    },
    (args) => {
      const name = args.userName ?? "there";

      const promptText = `Welcome ${name}!

Let's get you started:

1. **Explore Features**: Browse available tools and prompts
2. **Try Examples**: Test with sample data
3. **Read Docs**: Check out guides and references
4. **Get Help**: Use the /help command anytime

What would you like to do first?`;

      return {
        messages: [
          {
            role: "assistant",
            content: { type: "text", text: promptText },
          },
        ],
      };
    }
  );
}

Task Templates

Provide structure for common tasks:

typescript
export function registerTaskPrompt(server: McpServer): void {
  server.registerPrompt(
    "task-template",
    {
      description: "Generate structured task prompt",
      argsSchema: {
        taskType: z.enum(["bug-fix", "feature", "refactor", "docs"]),
        context: z.string().optional(),
      },
    },
    (args) => {
      const templates = {
        "bug-fix": `## Bug Fix Task

**Context:** ${args.context ?? "N/A"}

Please help with:
1. Reproduce the issue
2. Identify root cause
3. Implement fix
4. Add test coverage
5. Verify resolution`,

        feature: `## Feature Implementation

**Context:** ${args.context ?? "N/A"}

Let's implement this feature:
1. Design the solution
2. Write the code
3. Add tests
4. Update documentation
5. Review changes`,
      };

      return {
        messages: [
          {
            role: "assistant",
            content: {
              type: "text",
              text: templates[args.taskType] ?? "Task template",
            },
          },
        ],
      };
    }
  );
}

Troubleshooting

Prompt Not Listed

Issue: Prompt doesn't appear in prompts/list

Solutions:

  1. Check registration in src/mcp/prompts/index.ts:

    typescript
    registerMyPrompt(server); // Must be called
  2. Verify prompt name is unique (no duplicates)

  3. Check server logs for registration errors

Arguments Not Validated

Issue: Invalid arguments not rejected

Solution: Ensure Zod schema is properly defined:

typescript
// Bad: No validation
argsSchema: {}

// Good: Proper validation
argsSchema: {
  name: z.string().min(1).describe("Required name field"),
  age: z.number().positive().describe("Must be positive number"),
}

User Context Undefined

Issue: extra?._meta?.user is always undefined

Possible causes:

  1. AUTH_REQUIRED=false - User context disabled
  2. Missing or invalid JWT token
  3. Token claims don't include expected fields

Solution:

typescript
const userContext = extra?._meta?.user as UserContext | undefined;

if (!userContext) {
  logger.warn("User context unavailable", {
    authRequired: process.env.AUTH_REQUIRED,
    category: "mcp-prompt",
  });

  // Return generic prompt or error message
  return {
    messages: [
      {
        role: "assistant",
        content: {
          type: "text",
          text: "User context not available",
        },
      },
    ],
  };
}

Released under the MIT License.