
Mcp Expert
- 61 installs
- 14 repo stars
- Updated March 2, 2026
- oakoss/agent-skills
Helps with ai & agent building tasks during AI-assisted development.
About
mcp-expert is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- mcp-expert
- AI & Agent Building
- AI-coding skill
Mcp Expert by the numbers
- 61 all-time installs (skills.sh)
- +1 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #6,381 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/oakoss/agent-skills --skill mcp-expertAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 61 |
|---|---|
| repo stars | ★ 14 |
| Last updated | March 2, 2026 |
| Repository | oakoss/agent-skills ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
MCP Expert
Overview
Definitive resource for building and managing Model Context Protocol ecosystems. Covers the full lifecycle from server development and transport configuration to security, elicitation, and async task execution. Designs agent-first tool interfaces that minimize token usage and maximize LLM accuracy.
When to use: Building MCP servers, designing tool interfaces, configuring transports (Stdio/Streamable HTTP), implementing OAuth 2.1, troubleshooting connectivity, using elicitation for server-initiated user interaction.
When NOT to use: Application-level business logic, non-MCP API design, frontend UI components, general HTTP API development unrelated to MCP.
Quick Reference
| Pattern | API/Approach | Key Points |
|---|---|---|
| Outcome-oriented tools | Single-call operations | Avoid chatty multi-step APIs |
| Argument flattening | Flat Zod schemas with descriptions | Reduce model hallucination |
| Progressive disclosure | Return mcp:// URIs for large data | Agent reads partially via resources/read |
| Retryable tools | Return retry_with suggestions | Help LLM self-correct on bad inputs |
| Helpful errors | Descriptive strings with fix guidance | Never return raw exceptions or stack traces |
| Pagination | has_more + next_cursor metadata | 20-50 records per call maximum |
| Structured outputs | JSON Schema in outputSchema | Typed tool results for programmatic consumption |
| Stdio transport | Local tools, stderr-only logging | Never write to stdout except JSON-RPC |
| Streamable HTTP | Remote/cloud tools, single endpoint | Replaces deprecated SSE transport |
| OAuth 2.1 with PKCE | Enterprise data access | MCP server is resource server, not auth server |
| Elicitation | Server-initiated user input | Flat schema maps to forms, three response states |
| Async tasks | tasks/get, tasks/cancel | Long-running operations return task handles |
| Capability scopes | read:docs, write:code, admin:users | Granular permission boundaries |
| HITL gate | confirmation_required flag | Manual approval for destructive actions |
| URL elicitation | Secure credential collection | Redirects user to browser for sensitive input |
| Extensions | Namespaced capability negotiation | Optional features without forking the spec |
| Sampling with tools | Server-initiated LLM requests | Enables server-side agent loops |
| MCP Inspector | npx @modelcontextprotocol/inspector | Test tools interactively without an LLM |
| Unit testing | InMemoryTransport + Vitest | Test tool handlers with mock client |
| Evaluation framework | Schema, error, pagination assertions | Validate tool quality for LLM consumption |
| Tool contract testing | Schema field descriptions, edge cases | Every field described, optional params handled |
| CI smoke tests | GitHub Actions + Inspector CLI | Automated build, test, and transport health check |
Spec Versions
The current MCP specification is 2025-11-25. Key milestones:
- 2025-03-26: Streamable HTTP transport (deprecated standalone SSE), tool annotations
- 2025-06-18: Structured tool outputs, elicitation, OAuth auth server separation
- 2025-11-25: Async tasks, extensions framework, sampling with tools, CIMD auth, server discovery
Onboarding Protocol
When an agent needs a new capability:
1. Validation: Check if the required MCP server is already active 2. Guide: If missing, provide the user with a direct installation path 3. Config: Use uvx or npx for zero-install execution where possible
Common Mistakes
| Mistake | Correct Pattern |
|---|---|
| Designing chatty APIs requiring many round-trips | Build outcome-oriented tools that accomplish tasks in a single call |
| Logging to stdout breaking the Stdio transport | Log only to stderr to keep the transport channel clean |
| Returning raw exceptions and stack traces to the LLM | Return helpful error strings with suggested corrections |
| Building monolithic servers with dozens of tools | Keep servers focused with 5-15 tools for discoverability and maintenance |
| Using deprecated HTTP+SSE transport for new servers | Use Streamable HTTP for remote servers (single endpoint, optional SSE) |
| Hardcoding secrets in MCP server configuration | Use environment variable mapping for all sensitive values |
| Returning large datasets in a single tool call | Use pagination (20-50 records) or resource URIs for large data |
| Implementing auth server inside MCP server | MCP server acts as OAuth 2.1 resource server only, delegate auth externally |
| Vague tool descriptions causing LLM hallucination | Add example usage and strict constraints to the tool description field |
| Synchronous blocking on long operations | Return async task handles for operations exceeding timeout thresholds |
| Passing received tokens to upstream APIs | Validate tokens locally; never forward to avoid confused deputy attacks |
| Requesting secrets through elicitation | Use URL mode elicitation or dedicated OAuth flows for credential collection |
MCP Primitives
MCP servers expose three core primitive types to clients:
- Tools: Model-controlled actions the LLM invokes to accomplish tasks (function calling)
- Resources: Application-controlled data exposed via
mcp://URIs that clients read on demand - Prompts: User-controlled templates that provide structured context for specific workflows
Servers declare which primitives they support during capability negotiation in the initialize handshake. The November 2025 spec adds Tasks as an experimental primitive for async execution.
Transport Selection
| Scenario | Transport | Notes |
|---|---|---|
| Local CLI tools | Stdio | Fastest startup, no network overhead |
| Remote/cloud servers | Streamable HTTP | Single endpoint, optional SSE streaming |
| Legacy remote | HTTP+SSE | Deprecated since 2025-03-26, migrate to Streamable HTTP |
Clients should support Stdio whenever possible. Streamable HTTP supports both stateless and stateful server implementations via optional Mcp-Session-Id headers.
Delegation
- Discover and audit existing MCP server configurations: Use
Exploreagent to check active servers, verify connectivity, and identify missing capabilities - Build and deploy a new MCP server with validation and auth: Use
Taskagent to scaffold the server, implement Zod validation, and configure OAuth - Design MCP ecosystem architecture for multi-agent workflows: Use
Planagent to map tool boundaries, transport selection, and security scoping
References
- Server development, tool design, argument flattening, pagination, and error handling
- Transport configuration: Stdio, Streamable HTTP, and migration from SSE
- Security, OAuth 2.1 integration, elicitation, capability scopes, and HITL gates
- Testing and evaluation: MCP Inspector, unit tests, contract tests, CI integration
- Troubleshooting guide, MCP Inspector, common errors, and transport debugging
Security and Auth
OAuth 2.1 Authorization
MCP uses OAuth 2.1 for HTTP-based transports. The architecture separates three roles:
- MCP Server: Acts as an OAuth 2.1 resource server -- validates tokens, never issues them
- MCP Client: Acts as an OAuth 2.1 client -- requests tokens on behalf of the user
- Authorization Server: External identity provider that handles user authentication and token issuance
This separation was formalized in the 2025-06-18 spec. MCP servers must never implement their own auth server.
Authorization Flow
1. Client connects to MCP server and receives HTTP 401 with WWW-Authenticate header 2. Client discovers the authorization server via RFC 9728 (Protected Resource Metadata) 3. Client initiates OAuth 2.1 authorization code flow with mandatory PKCE (S256) 4. User authenticates in the browser and grants consent 5. Client receives access token and includes it in subsequent MCP requests 6. Server validates the token on every request, checking audience and scopes
PKCE Requirement
All clients must use PKCE with the S256 code challenge method. This is mandatory, not optional. PKCE protects public clients (agents, CLI tools, serverless functions) that cannot securely store client secrets.
Client ID Metadata Documents (CIMD)
The 2025-11-25 spec introduces CIMD as the preferred alternative to Dynamic Client Registration (DCR). Instead of registering with every server, clients publish a metadata document at a URL they control:
{
"client_id": "https://my-agent.example.com/client-metadata",
"client_name": "My AI Agent",
"redirect_uris": ["https://my-agent.example.com/callback"],
"grant_types": ["authorization_code"],
"response_types": ["code"],
"token_endpoint_auth_method": "none"
}Authorization servers fetch this document to learn about the client. This eliminates per-server registration friction.
Stdio Transport Auth
Stdio servers should not use OAuth. Instead, retrieve credentials from environment variables passed through the MCP configuration env object.
Elicitation
Elicitation (2025-06-18) allows MCP servers to request information from the user during execution. The server sends a flat schema describing what it needs, and the client renders a form.
Three Response States
- Accept: User provided the requested data (includes
contentmatching the schema) - Decline: User understood the request and explicitly refused
- Cancel: User dismissed the dialog without making a decision
Schema Design
Elicitation uses a flat schema -- no nested objects. This maps directly to a form UI:
const result = await ctx.elicit({
message: 'Which database should I connect to?',
requestedSchema: {
type: 'object',
properties: {
host: { type: 'string', default: 'localhost' },
port: { type: 'number', default: 5432 },
database: { type: 'string' },
},
required: ['host', 'database'],
},
});URL Mode Elicitation
The 2025-11-25 spec added URL mode elicitation for secure credential collection. Instead of collecting secrets through the client, redirect the user to a browser:
const result = await ctx.elicitUrl({
message: 'Please authenticate with the payment provider',
url: 'https://payments.example.com/auth',
reason: 'Required to process transactions',
});Credentials never transit through the MCP client. Use URL mode for API keys, passwords, and any PCI-compliant flows.
Security Boundary
Servers must not request sensitive information through standard elicitation. Elicitation is for configuration parameters and operational choices, not authentication credentials.
Capability-Based Security
Define granular scopes for your tools:
read:docs-- Agent can see documents but not editwrite:code-- Agent can suggest changes to codeadmin:users-- Agent can manage permissions (requires manual approval)
Scope boundaries prevent privilege escalation. An agent configured for read-only operations cannot accidentally execute write operations.
Human-in-the-Loop (HITL) Gate
For high-risk operations (deleting databases, executing shell commands, modifying permissions), the MCP server should return a confirmation flag:
interface HitlResponse {
is_done: false;
status: 'awaiting_approval';
message: 'I need to delete 50 files. Is this correct?';
metadata: { risk: 'high' };
}The host environment presents this to the user for explicit approval before proceeding.
Secret Management
- Never log API keys or access tokens in any output
- Pass secrets through the
envobject in MCP configuration, never hardcoded - Rotate server-to-server tokens on a regular schedule
- MCP servers must never forward received access tokens to upstream APIs (confused deputy prevention)
Audit Trails
Log every MCP interaction with:
- Timestamp
- Agent ID
- Tool name
- Arguments (masking sensitive fields)
- Outcome (success/failure and result summary)
Audit trails enable forensic analysis when agent behavior needs investigation.
Server Development
Project Scaffold
Directory Structure
my-mcp-server/
├── src/
│ ├── index.ts # Entry point, transport setup
│ ├── tools/ # Tool handlers
│ │ ├── search.ts
│ │ └── manage.ts
│ ├── resources/ # Resource providers
│ │ └── docs.ts
│ └── prompts/ # Prompt templates
│ └── workflows.ts
├── package.json
├── tsconfig.json
└── vitest.config.tspackage.json
{
"name": "my-mcp-server",
"version": "1.0.0",
"type": "module",
"bin": { "my-mcp-server": "./dist/index.js" },
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsx src/index.ts",
"test": "vitest run"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.0",
"zod": "^3.24.0"
},
"devDependencies": {
"tsx": "^4.19.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
}
}tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true
},
"include": ["src"]
}Entry Point (Stdio)
#!/usr/bin/env node
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { registerTools } from './tools/search.js';
import { registerResources } from './resources/docs.js';
const server = new McpServer({
name: 'my-mcp-server',
version: '1.0.0',
});
registerTools(server);
registerResources(server);
const transport = new StdioServerTransport();
await server.connect(transport);Entry Point (Streamable HTTP)
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { randomUUID } from 'node:crypto';
import express from 'express';
const app = express();
app.use(express.json());
const server = new McpServer({ name: 'my-mcp-server', version: '1.0.0' });
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
});
await server.connect(transport);
app.post('/mcp', (req, res) => transport.handleRequest(req, res));
app.get('/mcp', (req, res) => transport.handleRequest(req, res));
app.delete('/mcp', (req, res) => transport.handleRequest(req, res));
app.listen(3000, () => {
console.error('MCP server listening on port 3000');
});Tool Implementation Pattern
Register tools with Zod schemas for input validation, descriptions on every field, and structured error handling:
import { type McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
export function registerTools(server: McpServer) {
server.tool(
'search_documents',
'Search documents by keyword. Returns matching titles and snippets.',
{
query: z.string().min(2).describe('Search keyword (min 2 chars)'),
limit: z.number().min(1).max(50).default(20).describe('Max results'),
cursor: z
.string()
.optional()
.describe('Pagination cursor from previous call'),
},
async ({ query, limit, cursor }) => {
try {
const results = await searchIndex(query, limit, cursor);
return {
content: [
{
type: 'text',
text: JSON.stringify({
items: results.items,
has_more: results.hasMore,
next_cursor: results.nextCursor,
}),
},
],
};
} catch (error) {
return {
isError: true,
content: [
{
type: 'text',
text: `Search failed for query "${query}". Verify the index is available and try again.`,
},
],
};
}
},
);
}Error Handling Pattern
Wrap tool handlers to catch exceptions and return actionable messages:
function safeTool<T>(
handler: (
args: T,
) => Promise<{ content: Array<{ type: string; text: string }> }>,
) {
return async (args: T) => {
try {
return await handler(args);
} catch (error) {
const message =
error instanceof Error ? error.message : 'Unknown error occurred';
return {
isError: true as const,
content: [{ type: 'text' as const, text: `Error: ${message}` }],
};
}
};
}Resource Implementation
Resources expose data via URI templates that agents can read on demand. Use resources for large datasets, configuration, and documentation that tools can reference.
Static Resources
export function registerResources(server: McpServer) {
server.resource('project-config', 'config://project', async (uri) => ({
contents: [
{
uri: uri.href,
mimeType: 'application/json',
text: JSON.stringify({
version: '1.0.0',
environment: process.env.NODE_ENV ?? 'development',
}),
},
],
}));
}Resource Templates
Resource templates use URI patterns with parameters. Agents discover available templates via resources/list and read specific resources by filling in parameters:
server.resource(
'document',
'docs://documents/{docId}',
{ list: undefined },
async (uri, { docId }) => {
const doc = await db.documents.findUnique({ where: { id: docId } });
if (!doc) {
throw new Error(`Document ${docId} not found`);
}
return {
contents: [
{
uri: uri.href,
mimeType: 'text/markdown',
text: doc.content,
},
],
};
},
);Resource List Callback
Provide a list callback so agents can discover available resources:
server.resource(
'document',
'docs://documents/{docId}',
{
list: async () => {
const docs = await db.documents.findMany({
select: { id: true, title: true },
});
return {
resources: docs.map((doc) => ({
uri: `docs://documents/${doc.id}`,
name: doc.title,
mimeType: 'text/markdown',
})),
};
},
},
async (uri, { docId }) => {
const doc = await db.documents.findUnique({ where: { id: docId } });
return {
contents: [
{ uri: uri.href, mimeType: 'text/markdown', text: doc!.content },
],
};
},
);Prompt Templates
Prompts are server-defined templates that provide structured context for common workflows. Agents select prompts by name and fill in arguments.
server.prompt(
'summarize-document',
'Summarize a document by ID with a specified detail level',
{
docId: z.string().describe('Document ID to summarize'),
detail: z
.enum(['brief', 'detailed', 'executive'])
.default('brief')
.describe('Summary detail level'),
},
async ({ docId, detail }) => {
const doc = await db.documents.findUnique({ where: { id: docId } });
if (!doc) {
throw new Error(`Document ${docId} not found`);
}
return {
messages: [
{
role: 'user',
content: {
type: 'text',
text: `Summarize the following document at "${detail}" detail level:\n\n${doc.content}`,
},
},
],
};
},
);Multi-Message Prompts
Prompts can include system context and embedded resources:
server.prompt(
'code-review',
'Review code changes with project style guidelines',
{ diff: z.string().describe('Git diff to review') },
async ({ diff }) => ({
messages: [
{
role: 'user',
content: {
type: 'text',
text: `Review this diff for correctness, style, and potential issues:\n\n${diff}`,
},
},
],
}),
);Tool Design: Outcomes Over Operations
Do not mirror your REST API as individual tools. Agents struggle with excessive orchestration.
- Bad:
get_user_id,get_user_email,update_user_field(three calls for one task) - Good:
sync_user_profile(handles identification and multi-field updates in one call)
Design each tool to accomplish a meaningful outcome rather than a single CRUD operation.
Tool Annotations
The 2025-03-26 spec added tool annotations that help clients understand tool behavior without parsing descriptions:
server.tool(
'delete_records',
{ ids: z.array(z.string()).describe('Record IDs to delete') },
{ destructiveHint: true, idempotentHint: false, openWorldHint: false },
async ({ ids }) => {
await db.deleteMany(ids);
return {
content: [{ type: 'text', text: `Deleted ${ids.length} records` }],
};
},
);Annotations are hints, not guarantees. Clients should not rely on them for security decisions.
Argument Flattening and Type Safety
Use flat schemas with strict types to reduce model hallucination:
const sendAlertSchema = z.object({
channelId: z.string().describe('Target channel UUID'),
message: z.string().min(10).describe('Alert content (min 10 chars)'),
severity: z.enum(['low', 'high', 'critical']),
});Flat schemas with descriptive field annotations produce fewer hallucinated arguments than nested objects. Use z.enum() for fixed-value parameters and .describe() on every field.
Structured Tool Outputs
Since the 2025-06-18 spec, tools can declare an outputSchema to return typed JSON instead of plain text:
server.tool(
'get_weather',
{
city: z.string().describe('City name'),
},
{
outputSchema: z.object({
temperature: z.number(),
humidity: z.number(),
condition: z.string(),
}),
},
async ({ city }) => {
const data = await fetchWeather(city);
return {
structuredContent: {
temperature: data.temp,
humidity: data.humidity,
condition: data.condition,
},
};
},
);When outputSchema is defined, return structuredContent instead of content. Clients can process the result programmatically without parsing text.
Pagination and Resource Management
Never return large datasets in a single tool call. Large responses blow out the LLM context window.
- Standard Limit: 20-50 records per call
- Metadata: Always include
has_moreandnext_cursorfields - Resource URIs: For large files, return an
mcp://URI that the agent can read partially usingresources/read
Helpful Error Strings
Agents can self-correct if you give them a path forward:
- Bad:
Error: 400 Bad Request - Good:
Error: 'startDate' must be before 'endDate'. Please adjust your parameters and try again.
Include the field name, the constraint violated, and a suggested correction. Set isError: true in the tool result to signal failure.
Async Tasks (Experimental)
The 2025-11-25 spec introduces the Tasks primitive for long-running operations. Instead of blocking until completion, return a task handle:
- Server returns a task ID when the operation will exceed typical timeout thresholds
- Clients poll with
tasks/getto check status and retrieve results - Servers can publish progress updates via notifications
- Clients can cancel with
tasks/cancel
This is particularly useful for document processing, indexing, analytics jobs, and model inference.
Development Stack
- Runtime: Bun or Node.js with native TypeScript support
- SDK:
@modelcontextprotocol/sdk(TypeScript) ormcp(Python) - Validation: Zod or Valibot for runtime schema enforcement
- Server Size: Keep servers focused with 5-15 tools for discoverability and maintenance
- Spec Version: Target 2025-11-25 for the latest features
Testing and Evaluation
MCP Inspector
The MCP Inspector is the primary debugging tool for MCP servers. It connects to a server, lists capabilities, and lets you invoke tools interactively without an LLM.
Setup and Basic Usage
npx @modelcontextprotocol/inspector node dist/index.jsThe Inspector opens a web UI where you can:
- Browse
tools/list,resources/list, andprompts/listresponses - Invoke tools with manually crafted JSON inputs
- Inspect raw JSON-RPC messages on the wire
- Verify structured output matches declared
outputSchema
Connecting to Different Transports
# Stdio server (local)
npx @modelcontextprotocol/inspector node dist/index.js
# Streamable HTTP server (remote)
npx @modelcontextprotocol/inspector --url http://localhost:3000/mcp
# With environment variables
API_KEY=test-key npx @modelcontextprotocol/inspector node dist/index.jsInspector Checklist
Before connecting to any LLM host, verify in the Inspector:
1. tools/list returns all expected tools with descriptions on every field 2. Tool calls with valid inputs return expected results 3. Tool calls with invalid inputs return helpful error strings (not stack traces) 4. resources/list shows correct URIs with proper templates 5. Structured outputs match the declared outputSchema shape 6. Pagination returns has_more and next_cursor correctly
Unit Testing MCP Tools
Test tool handlers in isolation by calling them directly with mock context objects.
Test Setup
import { describe, expect, it, vi } from 'vitest';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
function createTestClient() {
const server = new McpServer({ name: 'test-server', version: '0.0.1' });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
return { server, clientTransport, serverTransport };
}Testing Tool Responses
describe('get_weather tool', () => {
it('returns structured weather data', async () => {
const { server, clientTransport, serverTransport } = createTestClient();
server.tool('get_weather', { city: z.string() }, async ({ city }) => ({
content: [{ type: 'text', text: JSON.stringify({ temp: 72, city }) }],
}));
const client = new Client({ name: 'test-client', version: '0.0.1' });
await Promise.all([
client.connect(clientTransport),
server.connect(serverTransport),
]);
const result = await client.callTool({
name: 'get_weather',
arguments: { city: 'Portland' },
});
expect(result.isError).toBeFalsy();
const parsed = JSON.parse(
(result.content as Array<{ text: string }>)[0].text,
);
expect(parsed.temp).toBe(72);
expect(parsed.city).toBe('Portland');
});
});Testing Error Responses
describe('error handling', () => {
it('returns helpful error for missing required field', async () => {
const { server, clientTransport, serverTransport } = createTestClient();
server.tool(
'search_docs',
{ query: z.string().min(3).describe('Search query (min 3 chars)') },
async ({ query }) => ({
content: [{ type: 'text', text: `Results for: ${query}` }],
}),
);
const client = new Client({ name: 'test-client', version: '0.0.1' });
await Promise.all([
client.connect(clientTransport),
server.connect(serverTransport),
]);
const result = await client.callTool({
name: 'search_docs',
arguments: { query: 'ab' },
});
expect(result.isError).toBe(true);
});
it('does not expose stack traces', async () => {
const { server, clientTransport, serverTransport } = createTestClient();
server.tool('failing_tool', {}, async () => {
throw new Error('Database connection failed');
});
const client = new Client({ name: 'test-client', version: '0.0.1' });
await Promise.all([
client.connect(clientTransport),
server.connect(serverTransport),
]);
const result = await client.callTool({
name: 'failing_tool',
arguments: {},
});
expect(result.isError).toBe(true);
const text = (result.content as Array<{ text: string }>)[0].text;
expect(text).not.toContain('at Object.');
expect(text).not.toContain('.ts:');
});
});Integration Testing
Integration tests verify the full server lifecycle including transport setup, capability negotiation, and multi-tool workflows.
Full Lifecycle Test
describe('server lifecycle', () => {
it('initializes and lists tools', async () => {
const { server, clientTransport, serverTransport } = createTestClient();
server.tool('ping', {}, async () => ({
content: [{ type: 'text', text: 'pong' }],
}));
const client = new Client({ name: 'test-client', version: '0.0.1' });
await Promise.all([
client.connect(clientTransport),
server.connect(serverTransport),
]);
const tools = await client.listTools();
expect(tools.tools).toHaveLength(1);
expect(tools.tools[0].name).toBe('ping');
});
it('handles sequential tool calls', async () => {
const { server, clientTransport, serverTransport } = createTestClient();
const items: string[] = [];
server.tool('add_item', { name: z.string() }, async ({ name }) => {
items.push(name);
return { content: [{ type: 'text', text: `Added: ${name}` }] };
});
server.tool('list_items', {}, async () => ({
content: [{ type: 'text', text: JSON.stringify(items) }],
}));
const client = new Client({ name: 'test-client', version: '0.0.1' });
await Promise.all([
client.connect(clientTransport),
server.connect(serverTransport),
]);
await client.callTool({ name: 'add_item', arguments: { name: 'first' } });
await client.callTool({ name: 'add_item', arguments: { name: 'second' } });
const result = await client.callTool({
name: 'list_items',
arguments: {},
});
const list = JSON.parse(
(result.content as Array<{ text: string }>)[0].text,
);
expect(list).toEqual(['first', 'second']);
});
});Evaluation Framework
Evaluation questions validate that your MCP server produces useful, well-structured responses that an LLM can act on.
Evaluation Criteria
| Criterion | Question |
|---|---|
| Structured data | Does the tool return parseable JSON, not free text? |
| Actionable errors | Can an LLM self-correct from the error message? |
| Pagination | Does has_more + next_cursor work end-to-end? |
| Schema accuracy | Does the response match the declared outputSchema? |
| Description quality | Would an LLM choose the right tool from description? |
| Edge case handling | What happens with empty inputs, missing params? |
| Token efficiency | Is the response concise enough for LLM context? |
Automated Evaluation Suite
import { describe, expect, it } from 'vitest';
describe('tool evaluation', () => {
it('returns structured JSON, not prose', async () => {
const result = await client.callTool({
name: 'search_users',
arguments: { query: 'jane' },
});
const text = (result.content as Array<{ text: string }>)[0].text;
expect(() => JSON.parse(text)).not.toThrow();
});
it('pagination terminates', async () => {
let cursor: string | undefined;
let pages = 0;
const maxPages = 100;
do {
const result = await client.callTool({
name: 'list_records',
arguments: { cursor, limit: 10 },
});
const data = JSON.parse(
(result.content as Array<{ text: string }>)[0].text,
);
cursor = data.next_cursor;
pages++;
} while (cursor && pages < maxPages);
expect(pages).toBeLessThan(maxPages);
});
it('error messages suggest fixes', async () => {
const result = await client.callTool({
name: 'create_record',
arguments: { title: '' },
});
expect(result.isError).toBe(true);
const text = (result.content as Array<{ text: string }>)[0].text;
expect(text).toMatch(/must|should|try|expected/i);
});
});Tool Contract Testing
Contract tests verify that tool input schemas match documentation and that edge cases are handled.
describe('tool contract', () => {
it('schema fields match tool description', async () => {
const tools = await client.listTools();
for (const tool of tools.tools) {
expect(tool.description).toBeTruthy();
if (tool.inputSchema.properties) {
for (const [key, prop] of Object.entries(tool.inputSchema.properties)) {
const schema = prop as { description?: string };
expect(schema.description).toBeTruthy();
}
}
}
});
it('handles missing optional params gracefully', async () => {
const tools = await client.listTools();
for (const tool of tools.tools) {
const required = new Set(tool.inputSchema.required ?? []);
const minimalArgs: Record<string, unknown> = {};
for (const key of required) {
minimalArgs[key] = getPlaceholderValue(
(tool.inputSchema.properties as Record<string, { type: string }>)[
key
],
);
}
const result = await client.callTool({
name: tool.name,
arguments: minimalArgs,
});
expect(result.isError).not.toBe(true);
}
});
});
function getPlaceholderValue(schema: { type: string }): unknown {
switch (schema.type) {
case 'string':
return 'test-value';
case 'number':
return 1;
case 'boolean':
return true;
case 'array':
return [];
default:
return {};
}
}Load Testing
Validate server behavior under concurrent tool calls.
describe('load testing', () => {
it('handles concurrent tool calls', async () => {
const concurrency = 20;
const calls = Array.from({ length: concurrency }, (_, i) =>
client.callTool({
name: 'get_record',
arguments: { id: `record-${i}` },
}),
);
const results = await Promise.all(calls);
for (const result of results) {
expect(result.isError).toBeFalsy();
}
});
it('respects timeout thresholds', async () => {
const start = Date.now();
const result = await Promise.race([
client.callTool({ name: 'slow_operation', arguments: {} }),
new Promise((resolve) =>
setTimeout(() => resolve({ isError: true, timedOut: true }), 30_000),
),
]);
const elapsed = Date.now() - start;
expect(elapsed).toBeLessThan(30_000);
});
});CI Integration
GitHub Actions Workflow
name: MCP Server Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 22
- run: npm ci
- run: npm run build
- run: npx vitest run
- name: MCP Inspector smoke test
run: |
timeout 10 npx @modelcontextprotocol/inspector node dist/index.js \
--cli --method tools/list || trueHealth Check for Streamable HTTP Servers
import { describe, expect, it } from 'vitest';
describe('health check', () => {
it('responds to initialization', async () => {
const response = await fetch('http://localhost:3000/mcp', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2025-11-25',
capabilities: {},
clientInfo: { name: 'health-check', version: '1.0.0' },
},
}),
});
expect(response.status).toBe(200);
const data = await response.json();
expect(data.result.serverInfo.name).toBeTruthy();
});
});Transports
Transport Overview
MCP uses JSON-RPC 2.0 over two standard transports. All messages must be UTF-8 encoded.
- Stdio: Communication over standard input/output for local process-based servers
- Streamable HTTP: HTTP POST and GET requests with optional SSE streaming for remote servers
The standalone HTTP+SSE transport from the 2024-11-05 spec is deprecated as of 2025-03-26.
Stdio Transport
Stdio is the preferred transport for local tools. The client launches the server as a child process and communicates via stdin/stdout.
Configuration
{
"my-server": {
"command": "npx",
"args": ["-y", "@example/mcp-server"],
"env": {
"API_KEY": "your-key-here"
}
}
}Critical Rules
- stdout is reserved for JSON-RPC only -- never write debug output, progress messages, or logs to stdout
- Use stderr for all logging -- diagnostic output goes to stderr where the host can capture it
- Messages are delimited by newlines; each JSON-RPC message is one line
- The server should exit cleanly when stdin closes
Common Patterns
const server = new McpServer({ name: 'my-server', version: '1.0.0' });
const transport = new StdioServerTransport();
await server.connect(transport);For Python servers, uvx provides zero-install execution:
{
"my-python-server": {
"command": "uvx",
"args": ["my-mcp-server"],
"env": { "API_KEY": "your-key-here" }
}
}Streamable HTTP Transport
Streamable HTTP is the standard transport for remote and cloud-hosted MCP servers. The server exposes a single HTTP endpoint (e.g., https://example.com/mcp) that accepts both POST and GET methods.
How It Works
- POST: Client sends JSON-RPC requests/notifications. Server responds with JSON-RPC responses, or optionally opens an SSE stream to send multiple messages.
- GET: Client opens an SSE stream to receive server-initiated messages (notifications, requests). Server may return 405 if it does not support server-initiated communication.
Session Management
Servers may assign a session ID during initialization via the Mcp-Session-Id response header. When present:
- Clients must include the
Mcp-Session-Idheader on all subsequent requests - The session ID must be globally unique and cryptographically secure
- Servers should return HTTP 404 for unknown session IDs
- Clients can terminate sessions with an HTTP DELETE to the endpoint
Stateless servers can omit session IDs entirely.
Server Implementation
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import express from 'express';
const app = express();
const server = new McpServer({ name: 'remote-server', version: '1.0.0' });
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
});
await server.connect(transport);
app.post('/mcp', (req, res) => transport.handleRequest(req, res));
app.get('/mcp', (req, res) => transport.handleRequest(req, res));
app.delete('/mcp', (req, res) => transport.handleRequest(req, res));
app.listen(3000);Required Headers
- Client must send
Accept: application/json, text/event-streamon POST requests - Client must send
Accept: text/event-streamon GET requests - Server returns
Content-Type: application/jsonfor single responses orContent-Type: text/event-streamfor streamed responses
Migrating from Deprecated HTTP+SSE
The legacy HTTP+SSE transport required two separate endpoints (one for SSE stream, one for POST messages) and a long-lived SSE connection. This caused problems with:
- High concurrency and latency under load
- Security tokens exposed in URL query strings
- Connection management complexity
Migration Steps
1. Replace the separate SSE and POST endpoints with a single MCP endpoint 2. Move from SSEServerTransport to StreamableHTTPServerTransport 3. Add session management via Mcp-Session-Id headers if needed 4. Update client configuration to point to the single endpoint URL
Backwards Compatibility
Servers can support both transports during migration:
- Keep the legacy SSE and POST endpoints active alongside the new MCP endpoint
- Clients can detect the transport by attempting a POST to the server URL first -- if it succeeds, the server supports Streamable HTTP; if it returns 4xx, fall back to legacy SSE
Transport Selection Guide
| Factor | Stdio | Streamable HTTP |
|---|---|---|
| Deployment | Local, same machine | Remote, cloud, multi-tenant |
| Latency | Minimal (no network) | Network-dependent |
| Auth | Environment variables | OAuth 2.1 with PKCE |
| Session state | Implicit (process lifetime) | Explicit (Mcp-Session-Id) |
| Server management | Client manages process | Independent process |
| Scaling | One client per server instance | Multiple clients per server |
Troubleshooting
Local Debugging with MCP Inspector
Use the MCP Inspector tool to test your server without an LLM:
npx @modelcontextprotocol/inspector <your-server-command>Verify these in the Inspector:
tools/listreturns the expected schema with descriptionsresources/listshows correct URIs- Tool calls with manual JSON inputs produce expected results
- Structured outputs match the declared
outputSchema
Common Errors
Method Not Found
- Cause: The server does not implement the requested JSON-RPC method (e.g.,
prompts/list) - Fix: Update your SDK and ensure all required handlers are registered. Check that your server declares the correct capabilities in the
initializeresponse.
Tool Call Timed Out
- Cause: The operation took longer than the host environment timeout (usually 30-60 seconds)
- Fix: Implement progress notifications for long-running operations, use the async Tasks primitive (2025-11-25 spec), or optimize the backend logic
LLM Hallucinated Arguments
- Cause: The tool description is vague or ambiguous
- Fix: Add example usage and strict constraints to the tool description field. Use Zod enums for fixed-value parameters. Use
.describe()on every schema field.
Invalid Session ID (HTTP 404)
- Cause: Client sent a request with an expired or unknown
Mcp-Session-Idheader - Fix: Re-initialize the connection. The server may have restarted or the session expired.
OAuth 401 Unauthorized
- Cause: Missing, expired, or invalid access token on an HTTP-based transport
- Fix: Check that the client completed the OAuth 2.1 flow. Verify the token audience matches the MCP server. Ensure PKCE was used with S256.
Host Log Locations
Check the logs of your host environment:
- Claude Desktop:
~/Library/Logs/Claude/mcp.log - Claude Code: Check stderr output in the terminal where the server was launched
- VS Code (Copilot): Check the Output panel for MCP-related channels
- Cursor: Check the Output panel for MCP logs
Environment Injection Issues
If your tools fail due to missing API keys:
- Check your
.mcp.jsonor MCP configuration file for the correct environment variable names - Ensure keys are passed through the
envobject in the MCP server configuration - Verify the shell environment has the variables set before launching the host
- For Stdio transport, environment variables are the primary auth mechanism
Stdio Transport Issues
- Symptoms: Server fails silently, no tool responses
- Common cause: Writing non-JSON-RPC output to stdout (debug logs, progress bars, print statements)
- Fix: Route all diagnostic output to stderr. In Node.js, use
console.error()for logging. In Python, usesys.stderr.write(). - Validation: Run with MCP Inspector to see raw JSON-RPC traffic
Streamable HTTP Transport Issues
- Connection refused: Verify the server is listening on the correct port and the MCP endpoint path is correct
- CORS errors: Configure appropriate CORS headers if the client is browser-based
- Session lost: Check that
Mcp-Session-Idheaders are being forwarded correctly through any proxies or load balancers - SSE stream drops: The server may close the SSE stream; clients should handle reconnection using the
Last-Event-IDheader - Stateless mode: If the server does not return a session ID, the client should not send one
Debugging Checklist
1. Test with MCP Inspector before connecting to an LLM host 2. Verify the server starts without errors on stderr 3. Check that all tool schemas have descriptions on every field 4. Confirm the transport matches the deployment model (Stdio for local, Streamable HTTP for remote) 5. For OAuth issues, verify the authorization server metadata at .well-known/oauth-authorization-server 6. For elicitation issues, ensure the schema is flat (no nested objects)