Used for feedback · quests · score card
Create an MCP-compliant server from scratch. You'll implement JSON-RPC 2.0, tool discovery, tool execution, and a real weather tool.
JSON-RPC 2.0 endpoint at /message
tools/list returning tool definitions
Real forecast from Yr/MET Norway API
Start with a basic HTTP server with a health check endpoint. This is the foundation everything else builds on.
from fastapi import FastAPI, Request from fastapi.responses import JSONResponse import uvicorn app = FastAPI(title="MCP Server") @app.get("/health") async def health(): return {"status": "ok", "service": "mcp-server"} if __name__ == "__main__": uvicorn.run(app, host="0.0.0.0", port=8000)
fastapi>=0.104.0 uvicorn[standard]>=0.24.0 httpx>=0.27.0 pydantic>=2.0.0
var builder = WebApplication.CreateBuilder(args); var app = builder.Build(); app.MapGet("/health", () => Results.Ok(new { status = "ok", service = "mcp-server" })); app.Run("http://0.0.0.0:8000");
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
import { Hono } from 'hono'; import { serve } from '@hono/node-server'; const app = new Hono(); app.get('/health', (c) => c.json({ status: 'ok', service: 'mcp-server' })); serve({ fetch: app.fetch, port: 8000 }); console.log('MCP Server running on port 8000');
{
"name": "mcp-server",
"type": "module",
"dependencies": {
"hono": "^4.13.3",
"@hono/node-server": "^2.1.1",
"zod": "^4.4.3"
}
}
package main import ( "encoding/json" "log" "net/http" "github.com/go-chi/chi/v5" ) func main() { r := chi.NewRouter() r.Get("/health", func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]string{ "status": "ok", "service": "mcp-server", }) }) log.Println("MCP Server running on port 8000") http.ListenAndServe(":8000", r) }
module mcp-server go 1.22 require github.com/go-chi/chi/v5 v5.0.12
package no.javazone.mcp; import java.util.Map; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; @SpringBootApplication public class McpServerApplication { public static void main(String[] args) { SpringApplication.run(McpServerApplication.class, args); } } @RestController class HealthController { @GetMapping("/health") Map<String, String> health() { return Map.of("status", "ok", "service", "mcp-server"); } }
from fastmcp import FastMCP mcp = FastMCP("MCP Server") if __name__ == "__main__": mcp.run(transport="streamable-http", host="0.0.0.0", port=8000)
fastmcp>=2.0.0 httpx>=0.27.0
Try it: curl http://localhost:8000/health — you should get the JSON health response.
MCP uses JSON-RPC 2.0 as its transport. Every message to the MCP server is a JSON-RPC request
with jsonrpc, method,
params, and id.
What 2026-07-28 changed.
The revision made MCP explicitly stateless: “all the information needed
to process a request is contained in the request itself.” There is no
initialize handshake and no session any more,
so everything that used to be established once now rides along on every request.
params._meta carries the protocol version and the client’s capabilitiesMCP-Protocol-Version, Mcp-Method, and Mcp-Name for the methods that have a name — so gateways can route without parsing JSON-32020 and HTTP 400-32601 is a 404, an untrusted Origin is 403, GET and DELETE on /message are 405resultType, and the cacheable ones add ttlMs and cacheScope
That validation is written for you, once per language, in the protocol module next to the
server — mcp_protocol.py,
protocol.go,
Protocol.java,
Protocol.cs,
protocol.ts. Read it once; the rules are the same in all five.
The wire contract they implement is docs/reference/mcp-2026-07-28-contract.md,
and make test-conformance holds your server to it.
import mcp_protocol from mcp_protocol import ProtocolError SUPPORTED_METHODS = ["server/discover", "tools/list", "tools/call"] @app.post("/message") async def handle_jsonrpc(request: Request): # Step 1: Origin, before we even read the body. An untrusted Origin is # code 1001 and HTTP 403 — DNS rebinding never gets to argue protocol. try: mcp_protocol.validate_origin(request.headers.get("origin")) except ProtocolError as exc: return exc.to_response() # Step 2: the JSON-RPC envelope. Every rejection here is HTTP 400. try: payload = await request.json() except Exception as exc: return ProtocolError(mcp_protocol.PARSE_ERROR, f"Parse error: {exc}", 400).to_response() if payload.get("jsonrpc") != "2.0": return ProtocolError(mcp_protocol.INVALID_REQUEST, "Invalid Request: only JSON-RPC 2.0 is supported", 400).to_response() # id MUST be present and MUST be a string or an integer. No id means a # notification, and this revision defines none over Streamable HTTP. if "id" not in payload: return ProtocolError(mcp_protocol.INVALID_REQUEST, "Invalid Request: this server accepts no notifications", 400).to_response() request_id = payload["id"] method = payload.get("method") try: # Step 3: _meta and the mirrored headers, in one call. Raises -32602 # when _meta is missing, -32020 when a header disagrees with the body, # -32022 on a protocol version we do not speak. All of them HTTP 400. mcp_request = mcp_protocol.validate_request( method=method, params=payload.get("params"), headers=request.headers, request_id=request_id, ) # Step 4: route. if method == "server/discover": return jsonrpc_result(request_id, await handle_server_discover()) if method == "tools/list": return jsonrpc_result(request_id, await handle_tools_list()) if method == "tools/call": # validate_request() already established that `name` exists and # that it matches the Mcp-Name header. name = mcp_request.params["name"] args = mcp_request.params.get("arguments", {}) return jsonrpc_result(request_id, await handle_tools_call(name, args)) # Unknown method -> 404, not 200. That is how a client tells a modern # server from a legacy HTTP+SSE one that has no endpoint here at all. raise mcp_protocol.method_not_found(method, SUPPORTED_METHODS) except ProtocolError as exc: return exc.to_response(request_id)
app.MapPost("/message", async (HttpRequest http, IHttpClientFactory httpFactory) => { object? id = null; try { // Step 1: Origin first. Untrusted Origin -> 1001 and HTTP 403. Protocol.ValidateOrigin( http.Headers.Origin.Count > 0 ? http.Headers.Origin.ToString() : null); // Step 2: the JSON-RPC envelope. Every rejection here is HTTP 400. var body = await new StreamReader(http.Body).ReadToEndAsync(); JsonDocument doc; try { doc = JsonDocument.Parse(body); } catch (JsonException e) { throw new ProtocolException(Protocol.ParseError, $"Parse error: {e.Message}", 400); } var root = doc.RootElement; if (!root.TryGetProperty("jsonrpc", out var version) || version.GetString() != "2.0") throw new ProtocolException(Protocol.InvalidRequest, "Invalid Request: only JSON-RPC 2.0 is supported", 400); // id MUST be present and MUST be a string or an integer. No id means a // notification, and this revision defines none over Streamable HTTP. if (!root.TryGetProperty("id", out var idEl)) throw new ProtocolException(Protocol.InvalidRequest, "Invalid Request: this server accepts no notifications", 400); id = idEl.ValueKind switch { JsonValueKind.String => idEl.GetString(), JsonValueKind.Number when idEl.TryGetInt64(out var n) => n, _ => throw new ProtocolException(Protocol.InvalidRequest, "Invalid Request: id must be a string or integer", 400) }; var method = root.GetProperty("method").GetString()!; var parameters = root.TryGetProperty("params", out var p) ? p : default; // Step 3: _meta and the mirrored headers. -32602 when _meta is missing, // -32020 when a header disagrees with the body, -32022 on a version we // do not speak. Protocol.ValidateRequest(method, parameters, new HeaderLookup(http)); // Step 4: route. Unknown method -> 404, not 200. object result = method switch { "server/discover" => ServerDiscover(), "tools/list" => ToolsList(), "tools/call" => await CallTool(parameters, httpFactory), _ => throw Protocol.MethodNotFound(method) }; return Results.Json(new JsonRpcResponse("2.0", id, Result: result), jsonOpts); } catch (ProtocolException e) { // The status code travels with the error code. This is the whole point. return Results.Json( new JsonRpcResponse("2.0", id, Error: new JsonRpcError(e.Code, e.Message, e.Data)), jsonOpts, statusCode: e.HttpStatus); } });
import { ProtocolError, validateOrigin, validateRequest, methodNotFound, PARSE_ERROR, INVALID_REQUEST, INTERNAL_ERROR, } from "./protocol.js"; app.post("/message", async (c) => { let id: unknown = null; try { // Step 1: Origin first. Untrusted Origin -> 1001 and HTTP 403. validateOrigin(c.req.header("Origin") ?? null); // Step 2: the JSON-RPC envelope. Every rejection here is HTTP 400. let body: unknown; try { body = await c.req.json(); } catch (err) { throw new ProtocolError(PARSE_ERROR, `Parse error: ${String(err)}`, 400); } const envelope = body as Record<string, unknown>; if (envelope.jsonrpc !== "2.0") { throw new ProtocolError( INVALID_REQUEST, "Invalid Request: only JSON-RPC 2.0 is supported", 400); } // id MUST be present and MUST be a string or an integer. No id means a // notification, and this revision defines none over Streamable HTTP. if (!("id" in envelope)) { throw new ProtocolError( INVALID_REQUEST, "Invalid Request: this server accepts no notifications", 400); } if (typeof envelope.id !== "string" && !Number.isInteger(envelope.id)) { throw new ProtocolError( INVALID_REQUEST, "Invalid Request: id must be a string or integer", 400); } id = envelope.id; const method = envelope.method as string; const params = (envelope.params ?? {}) as Record<string, unknown>; // Step 3: _meta and the mirrored headers. -32602 when _meta is missing, // -32020 when a header disagrees with the body, -32022 on a version we do // not speak. validateRequest(method, params, (name) => c.req.header(name)); // Step 4: route. Unknown method -> 404, not 200. switch (method) { case "server/discover": return c.json({ jsonrpc: "2.0", id, result: serverDiscover() }); case "tools/list": return c.json({ jsonrpc: "2.0", id, result: toolsList() }); case "tools/call": return c.json({ jsonrpc: "2.0", id, result: await callTool(params) }); default: throw methodNotFound(method); } } catch (err) { // The status code travels with the error code. This is the whole point. if (err instanceof ProtocolError) { return c.json(err.toBody(id), err.httpStatus as 400); } const internal = new ProtocolError( INTERNAL_ERROR, `Internal error: ${String(err)}`, 500); return c.json(internal.toBody(id), 500); } });
var supportedMethods = []string{"server/discover", "tools/list", "tools/call"} // handleMessage is THE MCP endpoint. The spec requires exactly one. func handleMessage(w http.ResponseWriter, r *http.Request) { // Step 1: Origin, before we even read the body. Untrusted Origin -> 1001 // and HTTP 403. DNS rebinding never gets to argue about protocol details. if perr := ValidateOrigin(r); perr != nil { perr.Write(w, nil) return } // Step 2: the JSON-RPC envelope. Every rejection here is HTTP 400. var req JSONRPCRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { (&ProtocolError{ Code: CodeParseError, Message: fmt.Sprintf("Parse error: %v", err), HTTPStatus: 400, }).Write(w, nil) return } if req.JSONRPC != "2.0" { (&ProtocolError{ Code: CodeInvalidRequest, Message: "Invalid Request: only JSON-RPC 2.0 is supported", HTTPStatus: 400, }).Write(w, nil) return } // id MUST be present and MUST be a string or an integer. No id means a // notification, and this revision defines none over Streamable HTTP. requestID, perr := parseRequestID(req.ID) if perr != nil { perr.Write(w, nil) return } // Step 3: _meta and the mirrored headers, in one call. -32602 when _meta // is missing, -32020 when a header disagrees with the body, -32022 on a // protocol version we do not speak. validated, perr := ValidateRequest(req.Method, req.Params, r, requestID) if perr != nil { perr.Write(w, requestID) return } // Step 4: route. switch req.Method { case "server/discover": writeResult(w, requestID, handleServerDiscover()) case "tools/list": writeResult(w, requestID, handleToolsList()) case "tools/call": // ValidateRequest already established that `name` exists and that it // matches the Mcp-Name header. result, callErr := handleToolsCall(validated) if callErr != nil { callErr.Write(w, requestID) return } writeResult(w, requestID, result) default: // Unknown method -> 404, not 200. MethodNotFound(req.Method, supportedMethods).Write(w, requestID) } }
@PostMapping(value = "/message", produces = MediaType.APPLICATION_JSON_VALUE) ResponseEntity<JsonRpcResponse> message( @RequestBody(required = false) String body, @RequestHeader(value = "Origin", required = false) String origin, @RequestHeader HttpHeaders headers) { // HttpHeaders rather than a Map: header names are case-insensitive on the // wire, and MCP writes them in mixed case. Protocol.HeaderLookup lookup = headers::getFirst; Object id = null; try { // Step 1: Origin first. Untrusted Origin -> 1001 and HTTP 403. Protocol.validateOrigin(origin); // Step 2: the JSON-RPC envelope. Every rejection here is HTTP 400. var request = parse(body); id = request.id(); if (!"2.0".equals(request.jsonrpc())) { throw new ProtocolException(Protocol.INVALID_REQUEST, "Invalid Request: only JSON-RPC 2.0 is supported", 400); } // id MUST be a string or an integer, and MUST NOT be null. No id means // a notification, and this revision defines none over Streamable HTTP. if (!(id instanceof String) && !(id instanceof Integer) && !(id instanceof Long)) { throw new ProtocolException(Protocol.INVALID_REQUEST, "Invalid Request: id must be a string or integer", 400); } var params = request.params() == null ? Map.<String, Object>of() : request.params(); // Step 3: _meta and the mirrored headers. -32602 when _meta is missing, // -32020 when a header disagrees with the body, -32022 on a protocol // version we do not speak. Protocol.validateRequest(request.method(), params, lookup); // Step 4: route. Unknown method -> 404, not 200. var result = switch (request.method()) { case "server/discover" -> serverDiscover(); case "tools/list" -> toolsList(); case "tools/call" -> callTool(params); default -> throw Protocol.methodNotFound(request.method()); }; return ResponseEntity.ok(JsonRpcResponse.ok(id, result)); } catch (ProtocolException e) { // The status code travels with the error code. This is the whole point. return ResponseEntity.status(e.httpStatus()).body(JsonRpcResponse.failure(id, e)); } catch (RuntimeException e) { var internal = new ProtocolException(Protocol.INTERNAL_ERROR, "Internal error: " + e.getMessage(), 500); return ResponseEntity.status(500).body(JsonRpcResponse.failure(id, internal)); } }
FastMCP handles JSON-RPC automatically.
Request parsing, method routing, and response formatting are all built in.
No models or dispatcher needed — jump straight to Step 4e to implement your tool.
The tools/list method returns the tool manifest — a list of available tools
with their names, descriptions, and input schemas. The agent reads this to know what functions it can call.
def handle_tools_list(): tools = [ { "name": "get_weather_forecast", "title": "Weather Forecast Provider", "description": "Fetch a weather forecast for a destination, with current " "conditions and a 5-day outlook", "inputSchema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": { "location": { "type": "string", "description": "City or place name, e.g. 'Oslo', 'Bergen', 'New York'", } }, "required": ["location"], "additionalProperties": False, }, }, # ADD YOUR OWN TOOLS HERE -- copy the block above and adapt it. ] # 2026-07-28 requires three things of any list result: # # resultType "complete" # ttlMs how long a client MAY treat the answer as fresh, >= 0 # cacheScope "public" when the answer is not user-specific # # The tool list is the same for everyone and only changes when someone edits # this file, so "public" and five minutes is plenty. return { "resultType": "complete", "tools": tools, "ttlMs": 300_000, "cacheScope": "public", "_meta": {mcp_protocol.META_SERVER_INFO: mcp_protocol.SERVER_INFO}, }
// The manifest the agent discovers. Add a tool here, add a case in CallTool, // restart -- the agent picks it up with no changes of its own. // // Protocol.Cacheable adds what 2026-07-28 requires of a list result: // resultType, ttlMs, cacheScope, and _meta with our server info. The list only // changes when someone edits this file, so five minutes is plenty. static Dictionary<string, object> ToolsList() => Protocol.Cacheable(300_000, new() { ["tools"] = new object[] { new { name = "get_weather_forecast", title = "Weather Forecast Provider", description = "Get the weather forecast for a destination, with current " + "conditions and a 5-day outlook", // A dictionary rather than an anonymous type, because "$schema" is // not a legal C# identifier. inputSchema = new Dictionary<string, object> { ["$schema"] = "https://json-schema.org/draft/2020-12/schema", ["type"] = "object", ["properties"] = new Dictionary<string, object> { ["location"] = new { type = "string", description = "City or place name, e.g. 'Oslo', 'Bergen', 'New York'" } }, ["required"] = new[] { "location" }, ["additionalProperties"] = false } } // ADD YOUR OWN TOOLS HERE -- copy the block above and adapt it. } });
const TOOLS = [ { name: "get_weather_forecast", title: "Weather Forecast Provider", description: "Get the weather forecast for a destination, with current conditions and a 5-day outlook", inputSchema: { $schema: "https://json-schema.org/draft/2020-12/schema", type: "object", properties: { location: { type: "string", description: "City or place name, e.g. 'Oslo', 'Bergen', 'New York'", }, }, required: ["location"], additionalProperties: false, }, }, // ADD YOUR OWN TOOLS HERE -- copy the block above and adapt it. ]; // cacheable() adds what 2026-07-28 requires of a list result: resultType, // ttlMs, cacheScope, and _meta with our server info. The tool list only changes // when someone edits this file, so five minutes is plenty. function toolsList() { return cacheable(300_000, { tools: TOOLS }); }
// handleToolsList is the manifest the agent discovers. Add a tool here, add a // case in handleToolsCall, restart -- the agent picks it up on its own. // // 2026-07-28 requires resultType, ttlMs and cacheScope on a list result. The // list is the same for everyone and only changes when someone edits this file, // so "public" and five minutes is plenty. func handleToolsList() map[string]any { return map[string]any{ "resultType": "complete", "tools": getToolDefinitions(), "ttlMs": 300000, "cacheScope": "public", "_meta": map[string]any{MetaServerInfo: ServerInfo}, } } func getToolDefinitions() []map[string]any { return []map[string]any{ { "name": "get_weather_forecast", "title": "Weather Forecast Provider", "description": "Get the weather forecast for a destination, with current " + "conditions and a 5-day outlook", "inputSchema": map[string]any{ "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": map[string]any{ "location": map[string]any{ "type": "string", "description": "City or place name, e.g. 'Oslo', 'Bergen', 'New York'", }, }, "required": []string{"location"}, "additionalProperties": false, }, }, // ADD YOUR OWN TOOLS HERE -- copy the block above and adapt it. } }
// Protocol.cacheable adds what 2026-07-28 requires of a list result: // resultType, ttlMs, cacheScope, and _meta with our server info. The tool list // only changes when someone edits this file, so five minutes is plenty. private static Map<String, Object> toolsList() { return Protocol.cacheable(300_000L, Map.of("tools", toolDefinitions())); } private static List<ToolDefinition> toolDefinitions() { return List.of(new ToolDefinition( "get_weather_forecast", "Weather Forecast Provider", "Get the weather forecast for a destination, with current conditions " + "and a 5-day outlook", Map.of( "$schema", "https://json-schema.org/draft/2020-12/schema", "type", "object", "properties", Map.of("location", Map.of( "type", "string", "description", "City or place name, e.g. 'Oslo', 'Bergen'")), "required", List.of("location"), "additionalProperties", false))); // ADD YOUR OWN TOOLS HERE -- add another ToolDefinition to the list above. }
FastMCP auto-generates tool discovery.
The tools/list response is built from your
@mcp.tool() decorators and Python type hints.
No manifest to write — skip to Step 4e.
Try it: curl -X POST http://localhost:8000/message -H "Content-Type: application/json" -H "MCP-Protocol-Version: 2026-07-28" -H "Mcp-Method: tools/list" -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}'
When the agent wants to use a tool, it sends a tools/call request
with the tool name and arguments. The server routes to the right handler and returns the result
in MCP's standard format.
async def handle_tools_call(tool_name: str, arguments: dict): # validate_request() has already established that `name` exists and that it # matches the Mcp-Name header. if tool_name == "get_weather_forecast": location = arguments.get("location") if not location: return { "resultType": "complete", "content": [{"type": "text", "text": "Missing required parameter: 'location'"}], "isError": True, } result = await get_weather_forecast(location) if "error" in result: # The tool ran and it went wrong. isError is for the model, which # can retry with different arguments. return { "resultType": "complete", "content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False)}], "isError": True, } # tools/call is NOT cacheable per the spec: resultType, but no ttlMs and # no cacheScope. A forecast is not a fact about the server, and # advertising a TTL on it means clients serve stale weather. return { "resultType": "complete", "content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False, indent=2)}], "structuredContent": result, "isError": False, } # An unknown tool is a PROTOCOL error, not a tool result. isError means "the # tool ran, but it went wrong" -- that message is for the model. A tool that # does not exist is the client asking for something absent, so it surfaces as # a JSON-RPC error and the client can re-fetch tools/list. raise ProtocolError( code=mcp_protocol.INVALID_PARAMS, message=f"Invalid params: unknown tool '{tool_name}'", http_status=400, )
async Task<object> CallTool(JsonElement parameters, IHttpClientFactory httpFactory) { // ValidateRequest has already established that 'name' exists and matches the // Mcp-Name header. var toolName = parameters.GetProperty("name").GetString()!; var arguments = parameters.TryGetProperty("arguments", out var a) ? a : default; return toolName switch { "get_weather_forecast" => await Weather(arguments, httpFactory), // An unknown tool is a PROTOCOL error, not a tool result. isError means // "the tool ran, but it went wrong" -- that message is for the model, // which can retry with different arguments. A tool that does not exist // is the client asking for something absent, so it surfaces as a // JSON-RPC error and the client can re-fetch tools/list. _ => throw new ProtocolException(Protocol.InvalidParams, $"Invalid params: unknown tool '{toolName}'", 400) }; } // tools/call carries resultType but NOT ttlMs or cacheScope: a forecast is not // a fact about the server, and advertising a TTL on it means clients serve // stale weather. static object ToolSuccess(string text, object structuredContent) => new { resultType = "complete", content = new[] { new { type = "text", text } }, structuredContent, isError = false }; static object ToolError(string message) => new { resultType = "complete", content = new[] { new { type = "text", text = message } }, isError = true };
// tools/call carries resultType but NOT ttlMs or cacheScope: a forecast is not // a fact about the server, and advertising a TTL on it means clients serve // stale weather. interface ToolResult { resultType: "complete"; content: Array<{ type: string; text: string }>; structuredContent?: Record<string, unknown>; isError: boolean; } function toolSuccess(text: string, structuredContent: Record<string, unknown>): ToolResult { return { resultType: "complete", content: [{ type: "text", text }], structuredContent, isError: false }; } function toolError(message: string): ToolResult { return { resultType: "complete", content: [{ type: "text", text: message }], isError: true }; } async function callTool(params: Record<string, unknown>): Promise<ToolResult> { // validateRequest has already established that `name` exists and matches the // Mcp-Name header. const name = params.name as string; const args = (params.arguments as Record<string, unknown>) ?? {}; switch (name) { case "get_weather_forecast": return getWeatherForecast(args); default: // An unknown tool is a PROTOCOL error, not a tool result. isError means // "the tool ran, but it went wrong" -- that message is for the model, // which can retry with different arguments. A tool that does not exist is // the client asking for something absent, so it surfaces as a JSON-RPC // error and the client can re-fetch tools/list. throw new ProtocolError(INVALID_PARAMS, `Invalid params: unknown tool '${name}'`, 400); } }
// MCPToolResult carries resultType but NOT ttlMs or cacheScope: a forecast is // not a fact about the server, and advertising a TTL on it means clients serve // stale weather. type MCPToolResult struct { ResultType string `json:"resultType"` Content []MCPContent `json:"content"` StructuredContent any `json:"structuredContent,omitempty"` IsError bool `json:"isError"` } func toolError(message string) MCPToolResult { return MCPToolResult{ ResultType: "complete", Content: []MCPContent{{Type: "text", Text: message}}, IsError: true, } } func handleToolsCall(req *ValidatedRequest) (any, *ProtocolError) { // ValidateRequest has already established that `name` exists and matches // the Mcp-Name header. var toolName string _ = json.Unmarshal(req.Params["name"], &toolName) arguments := map[string]any{} if raw, ok := req.Params["arguments"]; ok { _ = json.Unmarshal(raw, &arguments) } switch toolName { case "get_weather_forecast": location, _ := arguments["location"].(string) if location == "" { return toolError("Missing required parameter: 'location'"), nil } result, err := getWeatherForecast(location) if err != nil { // The tool ran and it went wrong. That is isError, for the model, // which can retry with different arguments. return toolError(fmt.Sprintf("Could not fetch weather data: %v", err)), nil } pretty, _ := json.MarshalIndent(result, "", " ") return MCPToolResult{ ResultType: "complete", Content: []MCPContent{{Type: "text", Text: string(pretty)}}, StructuredContent: result, IsError: false, }, nil default: // An unknown tool is a PROTOCOL error, not a tool result. A tool that // does not exist is the client asking for something absent, so it // surfaces as a JSON-RPC error and the client can re-fetch tools/list. return nil, &ProtocolError{ Code: CodeInvalidParams, Message: fmt.Sprintf("Invalid params: unknown tool '%s'", toolName), HTTPStatus: 400, } } }
// A tools/call result carries resultType but NOT ttlMs or cacheScope: a // forecast is not a fact about the server, and advertising a TTL on it means // clients serve stale weather. record ToolResult(String resultType, List<Content> content, Object structuredContent, boolean isError) { static ToolResult success(String text, Object structuredContent) { return new ToolResult("complete", List.of(Content.text(text)), structuredContent, false); } static ToolResult error(String message) { return new ToolResult("complete", List.of(Content.text(message)), null, true); } } private ToolResult callTool(Map<String, Object> params) { // validateRequest has already established that 'name' exists and matches // the Mcp-Name header. var name = params.get("name").toString(); var tool = registry.get(name); if (tool == null) { // An unknown tool is a PROTOCOL error, not a tool result. isError means // "the tool ran, but it went wrong" -- that message is for the model, // which can retry with different arguments. A tool that does not exist // is the client asking for something absent, so it surfaces as a // JSON-RPC error and the client can re-fetch tools/list. throw new ProtocolException(Protocol.INVALID_PARAMS, "Invalid params: unknown tool '%s'".formatted(name), 400); } var arguments = params.get("arguments") instanceof Map<?, ?> m ? (Map<String, Object>) m : Map.<String, Object>of(); try { return tool.apply(arguments); } catch (RuntimeException e) { // The tool ran and threw. That is isError, for the model. return ToolResult.error("Tool '%s' failed: %s".formatted(name, e.getMessage())); } }
FastMCP handles tool routing automatically.
Tool calls are dispatched to the matching function by name. No routing switch needed — skip to Step 4e.
Now implement the actual weather tool. It does two things: (1) geocode the city name to coordinates using Nominatim, then (2) fetch the forecast from Yr/MET Norway API.
import httpx async def geocode(location: str) -> tuple[float, float]: """Convert city name to lat/lon using Nominatim.""" async with httpx.AsyncClient() as client: resp = await client.get( "https://nominatim.openstreetmap.org/search", params={"q": location, "format": "json", "limit": 1}, headers={"User-Agent": "MCPWorkshop/1.0"} ) data = resp.json() if not data: raise ValueError(f"Location not found: {location}") return float(data[0]["lat"]), float(data[0]["lon"]) async def get_weather_forecast(location: str) -> dict: """Fetch weather from Yr/MET Norway API.""" lat, lon = await geocode(location) async with httpx.AsyncClient() as client: resp = await client.get( f"https://api.met.no/weatherapi/locationforecast/2.0/compact", params={"lat": round(lat, 4), "lon": round(lon, 4)}, headers={"User-Agent": "MCPWorkshop/1.0"} ) data = resp.json() timeseries = data["properties"]["timeseries"][:6] forecasts = [] for entry in timeseries: details = entry["data"]["instant"]["details"] forecasts.append({ "time": entry["time"], "temperature_c": details["air_temperature"], "wind_speed_ms": details.get("wind_speed"), }) return { "location": location, "coordinates": {"lat": lat, "lon": lon}, "forecasts": forecasts }
using System.Text.Json; public static class WeatherTool { private static readonly HttpClient _http = new() { DefaultRequestHeaders = { { "User-Agent", "MCPWorkshop/1.0" } } }; public static async Task<object> GetForecast(string location) { // 1. Geocode var geoUrl = $"https://nominatim.openstreetmap.org/search?q={location}&format=json&limit=1"; var geoData = await _http.GetFromJsonAsync<JsonElement[]>(geoUrl); var lat = double.Parse(geoData![0].GetProperty("lat").GetString()!); var lon = double.Parse(geoData![0].GetProperty("lon").GetString()!); // 2. Fetch forecast from Yr/MET var wxUrl = $"https://api.met.no/weatherapi/locationforecast/2.0/compact?lat={lat:F4}&lon={lon:F4}"; var wxData = await _http.GetFromJsonAsync<JsonElement>(wxUrl); // ... parse timeseries (similar to Python) return new { location, coordinates = new { lat, lon }, forecasts = /* ... */ }; } }
async function geocode(location: string): Promise<[number, number]> { const resp = await fetch( `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(location)}&format=json&limit=1`, { headers: { 'User-Agent': 'MCPWorkshop/1.0' } } ); const data = await resp.json(); if (!data.length) throw new Error(`Location not found: ${location}`); return [parseFloat(data[0].lat), parseFloat(data[0].lon)]; } export async function getWeatherForecast(location: string) { const [lat, lon] = await geocode(location); const resp = await fetch( `https://api.met.no/weatherapi/locationforecast/2.0/compact?lat=${lat.toFixed(4)}&lon=${lon.toFixed(4)}`, { headers: { 'User-Agent': 'MCPWorkshop/1.0' } } ); const data = await resp.json(); const forecasts = data.properties.timeseries.slice(0, 6).map((e: any) => ({ time: e.time, temperature_c: e.data.instant.details.air_temperature, wind_speed_ms: e.data.instant.details.wind_speed, })); return { location, coordinates: { lat, lon }, forecasts }; }
func getWeatherForecast(location string) (map[string]interface{}, error) { // 1. Geocode geoURL := fmt.Sprintf( "https://nominatim.openstreetmap.org/search?q=%s&format=json&limit=1", url.QueryEscape(location)) req, _ := http.NewRequest("GET", geoURL, nil) req.Header.Set("User-Agent", "MCPWorkshop/1.0") resp, err := http.DefaultClient.Do(req) // ... parse lat/lon from response // 2. Fetch Yr forecast wxURL := fmt.Sprintf( "https://api.met.no/weatherapi/locationforecast/2.0/compact?lat=%.4f&lon=%.4f", lat, lon) // ... parse timeseries return map[string]interface{}{ "location": location, "coordinates": map[string]float64{"lat": lat, "lon": lon}, "forecasts": forecasts, }, nil }
package no.javazone.mcp; import java.util.List; import java.util.Map; import org.springframework.stereotype.Component; import org.springframework.web.client.RestClient; // Two hops: Nominatim turns a city name into coordinates, then Yr/MET Norway // turns coordinates into a forecast. Both require a real User-Agent - MET blocks // requests without one, and that is the single most common reason this tool // "mysteriously" stops working. @Component class WeatherTool { private static final String USER_AGENT = "mcp-workshop/1.0 github.com/mcp-workshop"; private final RestClient http = RestClient.builder() .defaultHeader("User-Agent", USER_AGENT) .build(); ToolResult call(Map<String, Object> arguments) { if (!(arguments.get("location") instanceof String name) || name.isBlank()) { return ToolResult.error("Missing or invalid 'city' parameter"); } try { var place = geocode(name); if (place == null) { return ToolResult.error("City '%s' not found".formatted(name)); } var d = forecast(place.lat(), place.lon()); if (d == null) { return ToolResult.error("No weather data available for '%s'".formatted(name)); } var text = "Weather in %s: %.1f°C, wind %.1f m/s, humidity %.0f%%. (Source: Yr/MET Norway)" .formatted(name, d.airTemperature(), d.windSpeed(), d.relativeHumidity()); // structuredContent is the machine-readable twin of the text above. var structured = Map.<String, Object>of( "location", name, "location", place.displayName(), "temperature", d.airTemperature(), "wind_speed", d.windSpeed(), "humidity", d.relativeHumidity(), "unit", "metric", "source", "Yr/MET Norway"); return ToolResult.success(text, structured); } catch (Exception e) { return ToolResult.error("Weather lookup failed: " + e.getMessage()); } } private NominatimResult geocode(String city) { var results = http.get() .uri("https://nominatim.openstreetmap.org/search?q={q}&format=json&limit=1", city) .retrieve() .body(NominatimResult[].class); return results == null || results.length == 0 ? null : results[0]; } private Details forecast(String lat, String lon) { var response = http.get() .uri("https://api.met.no/weatherapi/locationforecast/2.0/compact?lat={lat}&lon={lon}", lat, lon) .retrieve() .body(YrResponse.class); var series = response == null ? null : response.properties().timeseries(); return series == null || series.isEmpty() ? null : series.getFirst().data().instant().details(); } // Upstream is snake_case, so the field names have to be spelled out. private record NominatimResult(String lat, String lon, @JsonProperty("display_name") String displayName) {} private record YrResponse(Properties properties) {} private record Properties(List<Timeseries> timeseries) {} private record Timeseries(String time, Data data) {} private record Data(Instant instant) {} private record Instant(Details details) {} private record Details( @JsonProperty("air_temperature") double airTemperature, @JsonProperty("wind_speed") double windSpeed, @JsonProperty("relative_humidity") double relativeHumidity) {} }
from fastmcp import FastMCP import httpx mcp = FastMCP("MCP Server") async def geocode(location: str) -> tuple[float, float]: """Convert city name to lat/lon using Nominatim.""" async with httpx.AsyncClient() as client: resp = await client.get( "https://nominatim.openstreetmap.org/search", params={"q": location, "format": "json", "limit": 1}, headers={"User-Agent": "MCPWorkshop/1.0"} ) data = resp.json() if not data: raise ValueError(f"Location not found: {location}") return float(data[0]["lat"]), float(data[0]["lon"]) @mcp.tool() async def get_weather_forecast(location: str) -> dict: """Get weather forecast for a location. Returns temperature and wind for the next 6 hours.""" lat, lon = await geocode(location) async with httpx.AsyncClient() as client: resp = await client.get( "https://api.met.no/weatherapi/locationforecast/2.0/compact", params={"lat": round(lat, 4), "lon": round(lon, 4)}, headers={"User-Agent": "MCPWorkshop/1.0"} ) data = resp.json() forecasts = [] for entry in data["properties"]["timeseries"][:6]: details = entry["data"]["instant"]["details"] forecasts.append({ "time": entry["time"], "temperature_c": details["air_temperature"], "wind_speed_ms": details.get("wind_speed"), }) return { "location": location, "coordinates": {"lat": lat, "lon": lon}, "forecasts": forecasts, } if __name__ == "__main__": mcp.run(transport="streamable-http", host="0.0.0.0", port=8000)
curl -X POST http://localhost:8000/message \ -H "Content-Type: application/json" \ -H "MCP-Protocol-Version: 2026-07-28" \ -H "Mcp-Method: tools/call" \ -H "Mcp-Name: get_weather_forecast" \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_weather_forecast","arguments":{"location":"Oslo"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}'
# No handshake, no session: 2026-07-28 is stateless, so every # request carries its own _meta and mirrored headers. curl -X POST http://localhost:8000/mcp \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -H "MCP-Protocol-Version: 2026-07-28" \ -H "Mcp-Method: tools/call" \ -H "Mcp-Name: get_weather_forecast" \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_weather_forecast","arguments":{"location":"Oslo"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}'
curl -X POST http://localhost:8000/message \ -H "Content-Type: application/json" \ -H "MCP-Protocol-Version: 2026-07-28" \ -H "Mcp-Method: tools/call" \ -H "Mcp-Name: get_weather_forecast" \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_weather_forecast","arguments":{"location":"Oslo"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}'
curl -X POST http://localhost:8000/message \ -H "Content-Type: application/json" \ -H "MCP-Protocol-Version: 2026-07-28" \ -H "Mcp-Method: tools/call" \ -H "Mcp-Name: get_weather_forecast" \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_weather_forecast","arguments":{"location":"Oslo"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}'
curl -X POST http://localhost:8000/message \ -H "Content-Type: application/json" \ -H "MCP-Protocol-Version: 2026-07-28" \ -H "Mcp-Method: tools/call" \ -H "Mcp-Name: get_weather_forecast" \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_weather_forecast","arguments":{"location":"Oslo"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}'
curl -X POST http://localhost:8000/message \ -H "Content-Type: application/json" \ -H "MCP-Protocol-Version: 2026-07-28" \ -H "Mcp-Method: tools/call" \ -H "Mcp-Name: get_weather_forecast" \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_weather_forecast","arguments":{"location":"Oslo"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}'
Package your MCP server into a Docker container so it can run alongside the agent and web service.
FROM python:3.14-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . EXPOSE 8000 CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build WORKDIR /src COPY *.csproj . RUN dotnet restore COPY . . RUN dotnet publish -c Release -o /app FROM mcr.microsoft.com/dotnet/aspnet:10.0 WORKDIR /app COPY --from=build /app . EXPOSE 8000 ENTRYPOINT ["dotnet", "McpServer.dll"]
FROM node:26-alpine WORKDIR /app COPY package*.json . RUN npm ci COPY . . RUN npx tsc EXPOSE 8000 CMD ["node", "dist/index.js"]
FROM golang:1.27 AS build WORKDIR /src COPY go.* . RUN go mod download COPY . . RUN CGO_ENABLED=0 go build -o /app/mcp-server . FROM gcr.io/distroless/static COPY --from=build /app/mcp-server /mcp-server EXPOSE 8000 ENTRYPOINT ["/mcp-server"]
FROM maven:3-eclipse-temurin-25 AS builder WORKDIR /build # Dependencies first, so a code-only change does not re-download the world. COPY pom.xml . RUN mvn -B -q dependency:go-offline COPY src ./src RUN mvn -B -q package -DskipTests FROM eclipse-temurin:25-jre WORKDIR /app COPY --from=builder /build/target/mcp-server-1.0.0.jar app.jar EXPOSE 8000 ENV PORT=8000 ENTRYPOINT ["java", "-jar", "app.jar"]
FROM python:3.14-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . EXPOSE 8000 CMD ["python", "app.py"]
docker build -t mcp-server ./services/mcp-server
docker run -p 8000:8000 mcp-server
# In another terminal:
curl http://localhost:8000/health
curl -X POST http://localhost:8000/message \
-H "Content-Type: application/json" \
-H "MCP-Protocol-Version: 2026-07-28" \
-H "Mcp-Method: tools/list" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}'
tools/list
tools/call with a real weather API
Your feedback helps us improve the workshop.
Your handle is sent with this feedback; leave it blank in the header and the submission stays anonymous. Please keep personal data out of the comment too — no name, e-mail address or employer, yours or anyone else's.