Introduction
Knowing the API for registering prompts is only half the picture. The other half is deciding when to use a prompt versus a tool, how to compose prompts for complex workflows, and how to test prompts effectively. Good prompt design makes your MCP server intuitive and powerful for end users.
Key Concepts
- Prompts vs tools: prompts prepare the LLM with context and instructions; tools perform actions and return results
- Workflow starters: prompts are ideal as entry points that set up a multi-step interaction
- Prompt composition: break complex workflows into focused prompts that can be combined
- Local testing: test prompt handlers by calling them directly with sample arguments
Real World Context
Consider a development assistant MCP server. Here is how you would divide responsibilities:
| Task | Primitive | Why |
|---|---|---|
| Review a pull request | Prompt | Sets up the review context and instructions for the LLM |
| Fetch PR diff from GitHub | Tool | Performs an API call and returns data |
| Read the project's style guide | Resource | Exposes static reference data |
| Generate test cases | Prompt | Structures the LLM's approach to test generation |
| Run the test suite | Tool | Executes a command and returns results |
Prompts and tools often work together: a prompt sets up the context, and the LLM uses tools to gather information and take action.
Deep Dive
The key question is: "Am I giving the LLM instructions, or am I giving it a capability?" Prompts are instructions; tools are capabilities.
typescriptimport { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; // This is a PROMPT — it structures how the LLM should think server.registerPrompt( 'analyze-performance', { title: 'Analyze Performance', description: 'Systematic performance analysis of a code module', argsSchema: z.object({ moduleName: z.string().describe('Name of the module to analyze') }) }, ({ moduleName }): GetPromptResult => ({ messages: [{ role: 'user', content: { type: 'text', text: [ `Perform a systematic performance analysis of the ${moduleName} module.`, '', 'Follow these steps:', '1. Read the module source code using the read-file tool', '2. Identify hot paths and frequently called functions', '3. Check for common performance anti-patterns:', ' - Unnecessary allocations in loops', ' - Missing memoization for expensive computations', ' - Synchronous I/O in async contexts', ' - N+1 query patterns', '4. Suggest specific improvements with code examples', '5. Estimate the impact of each suggestion (high/medium/low)' ].join('\n') } }] }) );
This prompt references tools ("use the read-file tool") but does not implement them — it guides the LLM's workflow while letting tools handle the actual data access.
Workflow starters are prompts designed to kick off a multi-step process. They set the context and let the LLM drive the subsequent tool calls.
typescriptserver.registerPrompt( 'debug-issue', { title: 'Debug Issue', description: 'Systematic debugging workflow for a reported issue', argsSchema: z.object({ issueDescription: z.string().describe('Description of the bug or issue'), reproSteps: z.string().optional().describe('Steps to reproduce') }) }, ({ issueDescription, reproSteps }): GetPromptResult => { const messages: GetPromptResult['messages'] = [ { role: 'user', content: { type: 'text', text: [ `Debug this issue: ${issueDescription}`, '', reproSteps ? `Reproduction steps: ${reproSteps}` : 'No reproduction steps provided.', '', 'Approach:', '1. Identify the likely source files involved', '2. Read the relevant code', '3. Form a hypothesis about the root cause', '4. Suggest a fix with before/after code' ].join('\n') } } ]; return { messages }; } );
The prompt structures the debugging approach but relies on the LLM to use tools for reading files and exploring the codebase.
Testing prompts locally is straightforward: call the handler directly and inspect the output.
typescript// Test a prompt handler directly const result = reviewHandler({ code: 'function add(a, b) { return a + b; }', language: 'typescript' }); console.log(JSON.stringify(result, null, 2)); // Verify: // - messages array is not empty // - roles are correct // - text includes the provided arguments // - instructions are clear and complete
Since prompt handlers are pure functions (input arguments in, messages out), they are easy to unit test without any server infrastructure.
Common Pitfalls
- Using a tool when a prompt is more appropriate: if the LLM needs to think and reason rather than fetch data, use a prompt
- Overloading a single prompt: one prompt should cover one workflow; split complex multi-phase processes into separate prompts
- Not referencing available tools: if your server also exposes tools, mention them in the prompt so the LLM knows they are available
- Skipping local testing: always test handlers with edge cases (empty strings, very long input, special characters) before deploying
Best Practices
- Design prompts as workflow entry points that guide the LLM through a structured process
- Reference your server's tools and resources in prompt text so the LLM knows what capabilities it has
- Keep each prompt focused on a single workflow — compose complex processes by using multiple prompts sequentially
- Write unit tests for prompt handlers by calling them directly with various argument combinations
- Use numbered steps in prompt text to give the LLM a clear sequence to follow
Summary
Prompt design in MCP is about choosing the right primitive for each task. Prompts provide instructions and structure; tools provide capabilities. Well-designed prompts serve as workflow starters that guide the LLM through multi-step processes, referencing tools and resources as needed. Test prompt handlers locally as pure functions to validate output quality before deployment.
Code Examples
server.registerPrompt(
'analyze-performance',
{
title: 'Analyze Performance',
description: 'Systematic performance analysis of a code module',
argsSchema: z.object({
moduleName: z.string().describe('Name of the module to analyze')
})
},
({ moduleName }): GetPromptResult => ({
messages: [{
role: 'user',
content: {
type: 'text',
text: `Analyze performance of ${moduleName}.\n\n1. Read the source code\n2. Identify hot paths\n3. Check for anti-patterns\n4. Suggest improvements`
}
}]
})
);// Call the handler directly to verify output
const result = reviewHandler({
code: 'function add(a, b) { return a + b; }',
language: 'typescript'
});
console.log(JSON.stringify(result, null, 2));
// Check: messages array, roles, argument interpolation