◉CLAUDE LABJP
●2.1.301 — Adds the vim % motion, LaTeX math rendering in the VS Code panel, and a retry-watchdog limit for usage-limit waits (10/15)●2.1.299 — Conversations over 200K tokens left idle about 54 minutes get compacted; MCP allow rules now match only the server they name●SONNET 4.5 — Retires on the Claude API on 11/30, 45 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●FAST MODE — Opus 5.5 fast mode is out of research preview and no longer needs the beta header (10/15)●NEW — I Put a Ten-Line Promise File at the To…●2.1.301 — Adds the vim % motion, LaTeX math rendering in the VS Code panel, and a retry-watchdog limit for usage-limit waits (10/15)●2.1.299 — Conversations over 200K tokens left idle about 54 minutes get compacted; MCP allow rules now match only the server they name●SONNET 4.5 — Retires on the Claude API on 11/30, 45 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●FAST MODE — Opus 5.5 fast mode is out of research preview and no longer needs the beta header (10/15)●NEW — I Put a Ten-Line Promise File at the To…
Articles/API & SDK
⬡ API & SDK/2026-06-02Advanced

Beyond Tools in MCP: Designing with Resources, Prompts, and Sampling

Cramming everything into MCP tools hits a wall fast. Here is how resources, prompts, and sampling untangle a server, told through a real wallpaper-app asset manager I cut from 14 tools down to 5.

MCP54Claude API128TypeScript24Server DesignIndie Development11

✦ Premium Article

I'm Masaki Hirokawa, an artist and indie developer. I want to walk back through the moment I started writing an MCP server so Claude could work directly with my wallpaper-app assets. My first server implemented every capability as a tool. Listing assets, reading metadata, running a fixed review routine, generating captions — all tools. It worked, but once the tool count crept up to fourteen, Claude started hesitating over which one to call, and I lost track of where things lived myself.

That was when it clicked that MCP offers resource, prompt, and sampling alongside tool, and I had been forcing things that belonged elsewhere into tools. Using the real refactor that cut those fourteen tools down to five, this piece lays out how to choose among the three primitives. The subject is the asset-management server for the wallpaper apps I've built as an indie developer since 2014 — apps that have passed 50 million downloads across iOS and Android.

What happens when tools carry everything

When you start with MCP, the first thing you reach for is almost always a tool. Register a function with server.tool() and Claude can call it — that simplicity is the draw. I went the same way. But expressing everything through tools surfaces a few concrete problems.

The first is that Claude's selection accuracy drops. Tools are presented to the model as operations with side effects. When read-only data fetches are also tools, the model has to judge "is this safe to call?" every time, and the more similarly named tools pile up, the more often it grabs the wrong one. On my server I kept both get_wallpaper_meta and get_wallpaper_info, and Claude regularly called the opposite of what I wanted.

The second is that routines can't be reused. "Review this wallpaper's metadata for the App Store" follows nearly the same steps every time. As a tool, the steps live in the prompt I rewrite on every call instead of accumulating on the server side.

The third is that there's nowhere to put light inference inside server processing. If I want to generate a human-facing caption from a filename, the server could hold an API key and call Claude itself — but then the server becomes the billing party, and cost management for a one-person operation gets complicated fast.

Here is a simplified version of the "everything is a tool" state before the refactor.

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: "wallpaper-assets", version: "1.0.0" });
 
// Read-only, yet all expressed as tools
server.tool("list_wallpapers", {}, async () => ({
  content: [{ type: "text", text: JSON.stringify(await db.listAll()) }],
}));
 
server.tool("get_wallpaper_meta", { id: z.string() }, async ({ id }) => ({
  content: [{ type: "text", text: JSON.stringify(await db.meta(id)) }],
}));
 
// A fixed review routine, also a tool (the caller rewrites the prompt each time)
server.tool("review_metadata", { id: z.string() }, async ({ id }) => ({
  content: [{ type: "text", text: `Review this metadata: ${JSON.stringify(await db.meta(id))}` }],
}));
 
// ... eleven more tools follow

That review_metadata is the telling one. All it does is assemble a review prompt and return it; it's a template, not an operation. It should have been a prompt from the start.

Resources: hand read-only data over as an address

A resource exposes the server's readable data under a URI. It's reachable at an address like wallpaper://1024, and the absence of side effects is expressed in its type. From Claude's vantage point it stops being "an operation to decide whether to call" and becomes "material it can reference," which separates it from the tool-selection noise.

The wallpaper list and individual metadata are exactly read-only data, so I moved them to resources.

import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
 
// The list is a fixed-URI resource
server.resource("wallpaper-list", "wallpaper://list", async (uri) => ({
  contents: [{
    uri: uri.href,
    mimeType: "application/json",
    text: JSON.stringify(await db.listAll()),
  }],
}));
 
// Individual metadata via a templated URI
server.resource(
  "wallpaper",
  new ResourceTemplate("wallpaper://{id}", { list: undefined }),
  async (uri, { id }) => ({
    contents: [{
      uri: uri.href,
      mimeType: "application/json",
      text: JSON.stringify(await db.meta(String(id))),
    }],
  })
);

That change alone removed three tools — list_wallpapers, get_wallpaper_meta, and get_wallpaper_info. Fetching data consolidated onto resources, and the mix-ups between similarly named tools dissolved on their own. I strongly recommend placing this line — reads are resources, writes and operations are tools — as the first fork in the design. What stays a tool is only the state-changing work, like re-running classification or regenerating a thumbnail.

One caveat: resources follow a "the client reads them explicitly" model. Claude doesn't silently read every resource in the flow of a conversation. When you want it to work with a specific wallpaper, the client has to attach that resource to the context or you have to nudge it to reference the resource. Misread this and it feels like "I made it a resource but Claude won't look at it" — but that's the designed behavior.

✦

Thank you for reading this far.

Continue Reading

What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.

WHAT YOU'LL LEARN
✦Concrete rules for choosing between resources, prompts, and sampling (reads become resources, human-triggered routines become prompts, in-server inference becomes sampling)
✦The actual refactor that took my 6-app wallpaper asset server from 14 tools to 5, with the TypeScript code
✦The client-support pitfall around sampling, and a fallback design that degrades quietly instead of breaking
Secure payment via Stripe · Cancel anytime
✦

Unlock This Article

Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.

or
Unlock all articles with Membership →
Share

Thank You for Reading

Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

Related Articles

⬡ API & SDK2026-05-20
Resolving Tool Name Collisions When Bundling Multiple MCP Servers in the Claude Agent SDK
When the GitHub MCP and Linear MCP both expose create_issue, Sonnet 4.6 cannot tell them apart. This article covers the structure of MCP tool name collisions, a TypeScript reconciler implementation, and a measured correction: the schemaHash I originally published missed all five schema drifts I tested it against.
⬡ API & SDK2026-05-04
Claude API on Bun in Production: Migration Decisions and Implementation Patterns That Actually Survive Real Traffic
A practical guide to running Claude API services on Bun in production. Covers migration triggers from Node.js, built-in SQLite/WebSocket usage, streaming optimization, and the pitfalls that only surface after deployment — with working code and measured numbers.
⬡ API & SDK2026-04-25
Claude API × Convex: Reactive AI Apps — Data Flow, Streaming, and Agent Patterns
How to combine Convex's reactive database with the Claude API to build chat and agent applications that hold up in production. Covers schema design, the Action/Mutation/Query boundary, streaming, tool-call state, and the cold-start pitfalls nobody warns you about.