Skip to content
Forward Deployed

Forward Deployed
Lessons and contents
Building MCP Servers & ClientsLesson 2 of 6
6 lessons
Learn

Single-server MCP, with resources

Source
From the RAG & Agents Engineering course
By
Sourav Banerjee
Last updated
9 Nov 2026

Report an issue

What comes back from a server

Larkspur must distinguish a missing server-side credential from an empty search result. You will read an MCP tool response, compare returned error data with protocol error status and decide what prompts and resources the host gives to the model.

Step 1: Inspect content and error status separately

Both projects build one FastMCP weather server and a LangGraph client that talks to it over stdio. Project 01 gives the server a tool, get_weather, and a prompt, compare_weather_prompt. Project 02 swaps the prompt for a resource, file://delivery_log, which reads nine order lines from delivery_log.txt. Lesson 5 of T12.1 explains who chooses each kind; this page is about what each one returns, because the client has to handle every shape.

Recorded with SDK 1.30.0 against copies of the two servers, with no API key set:

Request What the client gets
tools/call get_weather {"location": "Dublin"} One text block holding {"error": "OpenWeatherMap API key is not configured on the server."}, with isError: false
prompts/get compare_weather_prompt One message, role: "user", the filled-in instructions; both arguments are required
resources/list One resource, listed as file://delivery_log/ with a trailing slash, MIME type text/plain
resources/read file://delivery_log One text block holding a JSON array of the nine lines

Construct the two result shapes locally. An error key in tool content and the protocol isError flag are independent fields.

from mcp.types import CallToolResult, TextContent

returned = CallToolResult(content=[TextContent(type="text", text='{"error": "missing key"}')])
raised = CallToolResult(content=[TextContent(type="text", text="missing key")], isError=True)
print(returned.isError)
print(raised.isError)

Expected output

False
True

Step 2: Compare returned errors with raised failures

Three things in that table catch people out.

  • An error returned as data is a success. get_weather catches every failure and returns a dict with an error key, so the result carries isError: false and the model reads the message as weather data. A tool that raises is reported differently: a Larkspur search_handbook that raises ValueError("index not loaded") comes back with isError: true and the text Error executing tool search_handbook: index not loaded, and langchain-mcp-adapters turns that into a ToolMessage with status="error". Raise when the call failed; return data when it worked.
  • A list becomes one JSON string. The resource function returns list[str], and FastMCP serialises it as one text block, not one block per line. The client joins the text blocks it receives and pastes the result into a message, so the model sees a JSON array.
  • The return type decides the schema. get_weather is typed -> dict, and FastMCP lists it with no outputSchema, so the result has no structuredContent either. A typed return, such as a Pydantic model, gives the client a schema to check results against.

The project 03 copy of the server ships with OPENWEATHERMAP_API_KEY = "{{OPENWEATHERMAP_API_KEY}}". The guard only rejects an empty key or the literal YOUR_OPENWEATHERMAP_API_KEY, so the placeholder passes, the API answers 401, and the tool returns {"error": "Authentication failed…"}, again as a success.

Step 3: Trace what reaches the model

The client decides what reaches the model, and the two projects do it differently:

Diagram: User input; Starts with /resource?; read_resource, then ask what to do; Starts with /prompt?; check the argument count, get_prompt; Agent with toolsDiagram: User input; Starts with /resource?; read_resource, then ask what to do; Starts with /prompt?; check the argument count, get_prompt; Agent with tools

A prompt's text goes to the agent as if the user had typed it. A resource goes in wrapped as CONTEXT … TASK: <what you asked>, or with a request to acknowledge it and keep it in memory when you give no task; the MemorySaver checkpointer keeps it for the rest of the thread. Both servers read files and are started by relative path (python weather_server.py, Path("delivery_log.txt")), so the client only works when it is run from the project folder.

Step 4: Define the contract for each feature

For Larkspur, the same rules give one decision per feature. search_handbook raises when the index is missing and returns chunk ids and text otherwise; the refund policy can be a resource a support engineer attaches by hand; and a prompt like T12.1's answer_with_citations is something the engineer starts on purpose.

Try it yourself

Call the weather tool with no configured key and inspect both the text block and isError. Check that returning an error dictionary still reports false. Contrast that with a tool that raises, then decide which form the Larkspur client should treat as a failed operation rather than valid evidence.

Practice notebook

Run the complete implementation from the practice material after checking the trace or calculation above. Use its stated environment for live model or server calls; recorded model choices and timings are observations, not guaranteed outputs.

Continue learning

Run the notebooks

The code for this lesson is in these notebooks. Colab runs one in your browser: install the packages its first cell imports and set the API keys it reads. To run every notebook as written, clone the repository and use its uv environment.

Notebook GitHub Colab
Single server MCP Open on GitHub Run locally
Single server MCP with resources Open on GitHub Run locally

Key Takeaways

  • Raise when a tool call failed and return data when it worked. A caught error returned as a dict carries isError: false, so the model reads it as a real result.
  • FastMCP serialises a returned list as one text block, so the client pastes a single JSON array into the model's message.
  • Type the return value to get an outputSchema. A tool typed -> dict gives the client no schema and no structuredContent to check.

Check yourself

1. With no API key set, get_weather returns an error message with isError false. Why, and why is that a problem for the model?

get_weather catches every failure and returns a dict with an error key, so the call counts as a success and the model reads the message as weather data. A tool that raises comes back with isError: true, and langchain-mcp-adapters turns it into a ToolMessage with status="error". Raise when the call failed; return data when it worked.

2. The delivery log resource function returns a list of nine strings. What does the model see after the client reads it?

One JSON array. FastMCP serialises the list[str] as one text block, not one block per line, and the client joins the text blocks and pastes the result into a message, wrapped as CONTEXT … TASK: <what you asked>. The MemorySaver checkpointer keeps it for the rest of the thread.

3. You want the client to check get_weather results against a schema. Why does the server give it nothing to check, and what change fixes that?

get_weather is typed -> dict, so FastMCP lists it with no outputSchema and its results carry no structuredContent. The return type decides the schema: a typed return, such as a Pydantic model, gives the client a schema to check results against.

Rate your confidence and keep notes on the module page.