π§ MCP Fundamentals: Tools, Resources, Prompts, and Capability Negotiation (Deep Dive)
You know what MCP is β now let's go deep. A complete TypeScript reference covering every MCP primitive, capability negotiation under the hood, and how to bundle your server as an npm package so anyone can run it with a single npx command
Hi π, I'm Tushar Patil. Currently I am working as Frontend Developer (Angular) and also have expertise with .Net Core and Framework.
This is Part 2 of the AI Engineering with TypeScript series. Prerequisites: Read Part 1 β What is MCP? first. MCP SDK: @modelcontextprotocol/sdk v1.x Β· Protocol version: 2025-06-18
πΊοΈ What we'll cover
In Part 1 we built a simple task manager MCP server to understand the basics. Now we go deeper:
- π§ Tools β complete API, annotations, progress reporting, error handling
- π Resources β static vs dynamic, URI templates, list/read lifecycle
- π¬ Prompts β arguments, dynamic content, embedding resources
- π€ Capability negotiation β exactly what happens during initialize
- π¦ Packaging β bundle your server as an npm package and run it with npx Let's build a Weather + Forecast MCP server throughout this post β realistic enough to demonstrate every concept. βοΈ
π€ Part 1: Capability Negotiation β what really happens on connect
Before your first tool call, client and server run a handshake. Capability negotiation runs on connect: the client tells the server which protocol version and features it supports, the server replies with its own capabilities β and from there, both sides know which message types are available. This is what lets MCP evolve without breaking older clients. Here is the actual JSON-RPC exchange: β Client sends: initialize
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-06-18",
"capabilities": {
"roots": { "listChanged": true },
"sampling": {}
},
"clientInfo": {
"name": "claude-desktop",
"version": "1.2.0"
}
}
}
β Server responds: initialize result
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"protocolVersion": "2025-06-18",
"capabilities": {
"tools": { "listChanged": true },
"resources": {},
"prompts": {}
},
"serverInfo": {
"name": "weather-server",
"version": "1.0.0"
}
}
}
β Client sends: notifications/initialized (one-way, no response)
{ "jsonrpc": "2.0", "method": "notifications/initialized" }
Three things to notice here: protocolVersion is a date string, not semver β like "2025-06-18". The protocol uses date-based versioning because the spec evolves in discrete snapshots. The client sends the version it wants to speak; the server can accept it or reject it. Capabilities are declared, not assumed. If your server doesn't declare "tools": {}, clients won't call tools/list. If the server declares "tools": { "listChanged": true }, it's promising to send notifications/tools/list_changed whenever the tool list changes β the client will re-fetch on that notification. The SDK handles all of this for you. McpServer automatically declares capabilities based on what you register. You never write initialize handlers manually. β
π§ Part 2: Tools β the complete API
The anatomy of a tool
A tool has four parts:
server.registerTool(
'tool_name', // 1οΈβ£ Name β unique identifier, snake_case
{
description: '...', // 2οΈβ£ Description β what the LLM reads
inputSchema: { ... }, // 3οΈβ£ Input schema β Zod schema for type safety
annotations: { ... }, // 4οΈβ£ Annotations β hints about behavior (optional)
},
async (inputs, extra) => { // 5οΈβ£ Handler β your business logic
return { content: [...] }; // 6οΈβ£ Response β text, image, or resource content
}
);
Setting up the weather server
// src/index.ts
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
const server = new McpServer({
name: 'weather-server',
version: '1.0.0',
});
Tool annotations β telling the LLM how to behave
Annotations are hints that help AI clients understand a tool's risk profile before calling it.
server.registerTool(
'get_current_weather',
{
description: 'Get current weather conditions for a city',
inputSchema: {
city: z.string().describe('City name, e.g. "Pune" or "London"'),
country: z.string().length(2).optional().describe('ISO country code'),
units: z.enum(['metric', 'imperial']).default('metric'),
},
annotations: {
readOnlyHint: true, // π this tool only reads data β no side effects
idempotentHint: true, // π safe to call multiple times with same inputs
},
},
async ({ city, country, units }) => {
const location = country ? `\({city}, \){country}` : city;
const temp = units === 'metric' ? '28Β°C' : '82Β°F';
return {
content: [{
type: 'text',
text: `π€οΈ Current weather in \({location}:\n Temperature: \){temp}\n Condition: Partly Cloudy`,
}],
};
}
);
The four standard annotation hints are:
| Annotation | Meaning |
|---|---|
| readOnlyHint: true | Only reads data, no side effects |
| idempotentHint: true | Calling twice with same args = same result |
| destructiveHint: true | May delete or modify data β use with caution |
| openWorldHint: true | Interacts with external services |
Returning different content types
Tools can return text, image, or resource content:
server.registerTool(
'get_weather_map',
{
description: 'Get a weather radar map image for a region',
inputSchema: {
region: z.enum(['north', 'south', 'east', 'west']),
},
annotations: { readOnlyHint: true },
},
async ({ region }) => {
const imageBase64 = await fetchRadarImage(region); // returns base64 string
return {
content: [
{
type: 'text',
text: `πΊοΈ Weather radar for ${region} region:`,
},
{
type: 'image', // π return an image!
data: imageBase64,
mimeType: 'image/png',
},
],
};
}
);
Proper error handling in tools
Return isError: true for expected errors β don't throw. Throwing is for unexpected exceptions.
server.registerTool(
'get_forecast',
{
description: 'Get a 5-day weather forecast for a city',
inputSchema: { city: z.string().min(1) },
},
async ({ city }) => {
const cityData = await lookupCity(city);
if (!cityData) {
return {
content: [{
type: 'text',
text: `β City "${city}" not found.`,
}],
isError: true, // π tells the LLM this is an error, not a result
};
}
return { content: [{ type: 'text', text: 'Forecast data here...' }] };
}
);
Progress notifications for long-running tools
server.registerTool(
'analyze_weather_patterns',
{
description: 'Analyse historical weather patterns for a city',
inputSchema: { city: z.string(), years: z.number().int() },
},
async ({ city, years }, { reportProgress }) => {
for (let i = 0; i < years; i++) {
// π Report progress
await reportProgress({
progress: i + 1,
total: years,
message: `Analysing year ${i + 1}...`,
});
await analyseYear(city, i);
}
return {
content: [{ type: 'text', text: `β
Analysis complete` }],
};
}
);
π Part 3: Resources β exposing read-only data
Resources are read-only data sources identified by a URI. Think of them as GET endpoints β the AI reads them for context, but doesn't modify them.
Static resources
A static resource has a fixed URI:
server.registerResource(
'weather-stations',
'weather://stations/all',
{
name: 'All Weather Stations',
description: 'List of all monitored weather stations',
mimeType: 'application/json',
},
async () => {
const stations = await db.getStations();
return {
contents: [{
uri: 'weather://stations/all',
mimeType: 'application/json',
text: JSON.stringify(stations, null, 2),
}],
};
}
);
Dynamic resources with URI templates
URI templates let you expose a whole class of resources. Use {variable} placeholders:
import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
server.registerResource(
'city-weather-history',
new ResourceTemplate('weather://history/{city}/{year}', { list: undefined }),
{
name: 'City Weather History',
mimeType: 'application/json',
},
async (uri, { city, year }) => { // π variables extracted from URI
const data = await fetchHistory(city, parseInt(year));
return {
contents: [{
uri: uri.href,
mimeType: 'application/json',
text: JSON.stringify(data, null, 2),
}],
};
}
);
When to use resources vs tools
| Use a Resource when... | Use a Tool when... |
|---|---|
| The data is read-only | The operation has side effects |
| The URI uniquely identifies the data | Parameters determine the operation |
| It's like a GET endpoint | It's like a POST/PUT/DELETE endpoint |
π¬ Part 4: Prompts β reusable templates with arguments
Prompts are reusable templates that help users talk to models in a consistent way.
Embedding a resource inside a prompt
Prompts can pre-load resources as context:
server.registerPrompt(
'station-analysis',
{
name: 'Station Network Analysis',
description: 'Analyse the health of all weather stations',
},
async () => ({
messages: [{
role: 'user',
content: [
{
type: 'resource', // π embed a resource as context
resource: {
uri: 'weather://stations/all',
mimeType: 'application/json',
text: JSON.stringify(await fetchAllStations()),
},
},
{
type: 'text',
text: 'Based on the station data above, identify any offline stations.',
},
],
}],
})
);
π Part 5: Wiring it all together
Here's the complete src/index.ts with the stdio transport:
#!/usr/bin/env node
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
const server = new McpServer({
name: 'weather-server',
version: '1.0.0',
});
// --- Register Tools, Resources, Prompts here ---
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error('βοΈ Weather MCP Server running on stdio');
}
main().catch(console.error);
β οΈ The shebang line #!/usr/bin/env node at the very top is required for npx to execute the file directly.
π¦ Part 6: Packaging your MCP server as an npm package
Once you package your server, anyone can run it with a single command β no install instructions, just npx your-package-name. π
Step 1: Configure package.json for npx
The bin field allows the server to be run as a CLI command.
{
"name": "@yourname/weather-mcp",
"version": "1.0.0",
"type": "module",
"main": "dist/index.js",
"bin": {
"weather-mcp": "dist/index.js"
},
"files": [
"dist",
"README.md"
],
"scripts": {
"build": "tsc",
"prepublishOnly": "npm run build"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0",
"zod": "^3.25.0"
}
}
(Note: Ensure your build pipeline preserves the shebang line in your compiled dist/index.js file!)
Step 2: Build and test locally
Before publishing, test the full npx flow locally using npm link:
# In your server project directory
npm run build
npm link
# Now test it β exactly what users will do
npx weather-mcp
# Unlink when done
npm unlink weather-mcp
Step 3: Publish to npm π
npm login
npm publish --access public
Step 4: Use it in Claude Desktop with npx
Using npx -y downloads and runs the server if it's published to npm, making it easy for other developers to use your server without manual installation.
{
"mcpServers": {
"weather": {
"command": "npx",
"args": ["-y", "@yourname/weather-mcp"],
"env": {
"WEATHER_API_KEY": "your-api-key-here"
}
}
}
}
The -y flag skips the installation confirmation prompt. When Claude starts, it grabs the latest version automatically.
π― Summary
In this deep dive you learned:
- π€ Capability negotiation β what actually happens during initialize
- π§ Tools β annotations, content types, isError, progress reporting
- π Resources β static URIs, ResourceTemplate for dynamic URIs
- π¬ Prompts β argument-driven prompts and embedding resources
- π¦ npx packaging β bin field, shebang, and using with Claude Desktop In Part 3 we'll connect this server to an AI agent that plans, calls multiple tools in sequence, and reasons about the results. π€
π Further reading
- π MCP TypeScript SDK docs
- π§ Official SDK GitHub
- π§ͺ MCP Inspector
- π¦ MCP official server examples
- ποΈ MCP protocol spec
- β‘ Part 1: What is MCP?