Skip to main content

Command Palette

Search for a command to run...

πŸ”§ 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

Published
β€’9 min read
πŸ”§ MCP Fundamentals: Tools, Resources, Prompts, and Capability Negotiation (Deep Dive)
T

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