Module 6 · The Model Context Protocol (MCP) ⏱ 18 min

Tools, Resources, and Prompts: What a Server Offers

By the end of this lesson you will be able to:
  • Name the three primitives an MCP server exposes (tools, resources, prompts) and the actor that initiates each
  • Implement a tools/list handler that returns spec-shaped definitions with a JSON-Schema inputSchema
  • Distinguish a tool-level failure (isError) from a protocol-level JSON-RPC error

An MCP server does not expose a single shapeless endpoint that does anything you ask. It advertises three distinct primitives — tools, resources, and prompts — and the gap between them is not cosmetic. Each answers a different question, is requested with its own pair of methods, and is set in motion by a different actor. Confusing them is the most common mistake newcomers make: waiting for the model to read a resource only the application can read, or calling a prompt as if it were a tool.

This lesson names the three primitives precisely, gives the request shape for each, and has you implement the two methods that do the real work in practice — tools/list and tools/call — in plain JavaScript, with the exact object shapes the specification requires.

flowchart LR
  U["user"] --> PR["prompts<br/>user picks a template"]
  A["application"] --> RE["resources<br/>app reads data by URI"]
  M["model"] --> TO["tools<br/>model calls an action"]
  style PR fill:#1d4ed8,color:#fff
  style RE fill:#166534,color:#fff
  style TO fill:#7c3aed,color:#fff
Three primitives, three initiators: the user picks prompts, the application reads resources, the model calls tools.

Who initiates each primitive

A tool is an action the model can choose to call — search_web, create_issue, run_query. The model decides, from the conversation, whether and how to invoke it; tools are how an agent acts on the world, so they may have side effects. You enumerate them with tools/list and run one with tools/call.

A resource is data the application exposes to the model as context, addressed by a URI such as file:///report.md or db://users/42. The application, not the model, judges when a resource is relevant and reads it with resources/read. Resources carry information in; they change nothing.

A prompt is a reusable, parameterised template the user selects from a menu — 'Review this pull request', 'Explain this error'. The user picks one with prompts/get, supplies its arguments, and the server returns the assembled messages ready to send.

What tools/list returns

A tools/list call returns an array of definitions under the key tools. Each entry carries three fields the model depends on: a name (the identifier you pass to tools/call), a human-readable description (the text the model actually reads to judge whether the tool fits), and an inputSchema — a JSON Schema describing the arguments. That schema is not decoration: a precise inputSchema is how the model learns to call the tool correctly without worked examples. Resources and prompts echo this shape — resources/list entries carry a uri, name, and description; prompts/list entries carry a name, description, and a list of arguments.

flowchart LR
  L["tools/list"] --> R["array of definitions"]
  R --> T1["name + description + inputSchema"]
  R --> T2["name + description + inputSchema"]
  style R fill:#1d4ed8,color:#fff
  style T1 fill:#166534,color:#fff
  style T2 fill:#166534,color:#fff
tools/list returns one definition per tool, each with a name, a description, and a JSON-Schema inputSchema.
A tool registry and the tools/list handler that exposes it. Press Run.
const tools = [
  {
    name: 'add',
    description: 'Add two numbers and return the sum',
    inputSchema: {
      type: 'object',
      properties: { a: { type: 'number' }, b: { type: 'number' } },
      required: ['a', 'b']
    }
  },
  {
    name: 'uppercase',
    description: 'Uppercase a string',
    inputSchema: { type: 'object', properties: { s: { type: 'string' } }, required: ['s'] }
  }
];

function toolsList() {
  return { tools };
}

console.log(toolsList().tools.map(t => t.name).join(', '));
console.log(toolsList().tools[0].inputSchema.required[0]);

The list is a contract

Treat tools/list as a contract the server publishes, not a question it answers fresh each time. Hosts call it once, cache the result, and surface the cached name and description fields to the model whenever the conversation needs them. That makes the description field do real work: it is the only text the model sees when deciding whether a tool is relevant, so a vague entry like do stuff produces a tool the model never calls, while search the wiki and return up to ten pages earns calls. The inputSchema plays the same role for arguments — it tells the model that search_web wants a query and an optional limit without you showing an example call.

What tools/call takes and returns

Calling a tool is a tools/call request whose params are a name and an arguments object. The server runs the matching function and answers with a content array — an ordered list of typed chunks such as a text chunk or an image chunk. A successful call returns that array with no error flag. But a tool that ran and then failed at its job — the search returned nothing, the file was missing, the arguments were semantically wrong — is not reported as a protocol error. It returns content explaining the problem together with isError set to true. That layering is what the figure below makes exact.

A tools/call dispatcher: unknown tools and thrown errors become isError, never a JSON-RPC error. Press Run.
const registry = {
  add: ({ a, b }) => a + b,
  uppercase: ({ s }) => s.toUpperCase()
};

function toolsCall({ name, arguments: args }) {
  const fn = registry[name];
  if (!fn) {
    return { content: [{ type: 'text', text: 'unknown tool: ' + name }], isError: true };
  }
  try {
    const result = fn(args);
    return { content: [{ type: 'text', text: String(result) }], isError: false };
  } catch (e) {
    const msg = e && e.message ? e.message : String(e);
    return { content: [{ type: 'text', text: msg }], isError: true };
  }
}

console.log(toolsCall({ name: 'add', arguments: { a: 2, b: 3 } }).content[0].text);
console.log(toolsCall({ name: 'missing', arguments: {} }).isError);

Resources and prompts complete the set

Resources flow through resources/list (the catalogue) and resources/read (fetch one by URI, returning a contents array of typed chunks). Because they are cheap, read-only context that the application curates, a host can inject them into the prompt without asking permission each time. Prompts flow through prompts/list and prompts/get, the latter returning the assembled messages the user chose. The split exists so a host can apply different policies to each kind: tool calls may demand explicit user approval because they act, resources are passive context because they inform, and prompts are a user-facing shortcut because they save typing. One shapeless endpoint could never express that distinction.

Resources are URI-addressed data; prompts are named templates with arguments. Press Run.
const resources = [
  { uri: 'file:///notes.md', name: 'notes', description: 'Project notes' }
];
const prompts = [
  {
    name: 'summarise',
    description: 'Summarise a document',
    arguments: [{ name: 'uri', description: 'Document URI', required: true }]
  }
];

console.log(resources[0].uri);
console.log(prompts[0].arguments[0].name);
flowchart TD
  REQ["tools/call request"] --> RUN["server handles it"]
  RUN -->|"tool runs and succeeds"| OK["content, isError false"]
  RUN -->|"tool runs but fails"| ERR["content, isError true"]
  RUN -->|"tool never runs"| RPC["JSON-RPC error"]
  style OK fill:#166534,color:#fff
  style ERR fill:#b45309,color:#fff
  style RPC fill:#b91c1c,color:#fff
Two layers of failure: a tool that ran but failed returns isError, while a call the server could not execute returns a JSON-RPC error.
Exercise

What does this print? toolsList returns the definitions; .map pulls the name from each, and join turns the array into one string.

const tools = [
  { name: 'add', description: 'sum two numbers', inputSchema: { type: 'object' } },
  { name: 'get', description: 'fetch a record', inputSchema: { type: 'object' } }
];
function toolsList() { return { tools }; }
console.log(toolsList().tools.map(t => t.name).join(', '));
Exercise

Write toolsList() that returns { tools: [...] }, where the array holds one { name, description } object per entry in the definitions map (the key becomes name, the value becomes description).

const definitions = { add: 'Add two numbers', get: 'Fetch a record' };
function toolsList() {
  // return { tools: [...] } with one {name, description} per entry
}
Exercise

This toolsCall should report a tool-level failure with isError true and an explanatory content array. Instead its catch returns a JSON-RPC error object, which signals a malformed request, not a tool that ran and failed. Change only the catch block so a thrown error becomes { content, isError: true }.

const fn = ({ x }) => { if (x < 0) throw new Error('x must be non-negative'); return x * 2; };
function toolsCall({ arguments: args }) {
  try {
    const result = fn(args);
    return { content: [{ type: 'text', text: String(result) }], isError: false };
  } catch (e) {
    return { code: -32603, message: e.message };
  }
}
Exercise

Write toolsCall({ name, arguments }) that looks up name in registry and runs the matching function with arguments. On success return { content: [{ type: 'text', text: String(result) }], isError: false }. If the name is not in registry, return { content: [{ type: 'text', text: 'unknown tool' }], isError: true }. There is no try/catch here — an unknown tool is the only failure case.

const registry = { double: ({ n }) => n * 2, shout: ({ s }) => s.toUpperCase() };
function toolsCall({ name, arguments: args }) {
  // dispatch to registry[name](args); unknown name -> isError
}
Exercise

A user opens a menu, picks 'Review this pull request', and fills in a URL. Which primitive did they just use?

Recap

  • An MCP server offers three primitives, each started by a different actor: tools (the model calls actions), resources (the application reads data by URI), prompts (the user picks a template).
  • tools/list returns definitions with a name, a description, and a JSON-Schema inputSchema — the schema teaches the model how to call correctly.
  • tools/call takes a name and arguments and returns a content array of typed chunks.
  • A tool that ran but failed returns content with isError true. Reserve JSON-RPC errors for the protocol layer — a request that could not be executed at all.
  • Next you will wire handlers like these into a router that dispatches any incoming MCP method by name.

Checkpoint quiz

Which primitive is initiated by the application (not the model or the user)?

A tool ran successfully but returned no results (an empty search). How should the server report this?

Go deeper — technical resources