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
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
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.
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.
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
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(', '));
map builds a new array containing just each tool's name.
join(', ') stitches that array into one comma-separated string.
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
}
Object.entries(definitions) gives an array of [key, value] pairs.
Map each pair into { name: key, description: value } and wrap the array under a tools key.
const definitions = { add: 'Add two numbers', get: 'Fetch a record' };
function toolsList() {
return {
tools: Object.entries(definitions).map(function (entry) {
return { name: entry[0], description: entry[1] };
})
};
}
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 };
}
}
A tool that threw still ran — report it with content and isError true.
Keep the existing text-chunk shape; just swap the returned object in the catch block.
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 { content: [{ type: 'text', text: String(e.message) }], isError: true };
}
}
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
}
Read registry[name]; if it is undefined, the tool is unknown.
Otherwise call fn(args) and wrap String(result) in the content array.
const registry = { double: ({ n }) => n * 2, shout: ({ s }) => s.toUpperCase() };
function toolsCall({ name, arguments: args }) {
const fn = registry[name];
if (!fn) {
return { content: [{ type: 'text', text: 'unknown tool' }], isError: true };
}
return { content: [{ type: 'text', text: String(fn(args)) }], isError: false };
}
A user opens a menu, picks 'Review this pull request', and fills in a URL. Which primitive did they just use?
Prompts are reusable, parameterised templates the user chooses from a menu — exactly 'Review this pull request' with a URL argument. Tools are model-initiated actions; resources are application-read data.
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/listreturns definitions with aname, adescription, and a JSON-SchemainputSchema— the schema teaches the model how to call correctly.tools/calltakes anameandargumentsand returns acontentarray of typed chunks.- A tool that ran but failed returns
contentwithisErrortrue. 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.