Build MCP servers
How to build an MCP server in Java
A notes server in Java with the official SDK, running over stdio and Streamable HTTP behind an embedded Tomcat, checked with the SDK's own client and curl.
New to MCP? What is an MCP server? covers the basics first.
The Java SDK makes you write each tool's JSON Schema by hand, then validates every call against it before your handler runs. This tutorial builds a notes server with three tools that way. list_notes and read_note only read, and add_note writes a file, which matters later when you decide which agents get which tools.
The SDK's README says it is "maintained in collaboration with Spring AI," but the core mcp module needs no Spring: it bundles its own stdio, SSE and Streamable HTTP transports on plain Jakarta Servlet and the JDK HttpClient. Spring AI 2.0+ only adds WebFlux and WebMVC transports for projects already on Spring, so this tutorial sticks to the plain SDK. The Java SDK is Tier 2 on modelcontextprotocol.io/docs/sdk, below the Tier 1 Go, Rust, Python, TypeScript, C# and Ruby SDKs.
Prerequisites
- Java 17 or newer. The SDK's classes target Java 17. I used 21.0.12 (Amazon Corretto), so the
pom.xmlbelow setsmaven.compiler.releaseto 21. Set it to 17 on an older JDK. - Maven. I downloaded the 3.9.16 binary tarball; there is no official Java project scaffolding tool the way
uvorcargois. - Claude Code, if you want to try the server there in step 5.
Step 1: Create the project
Make a folder for the server, with Maven's standard source layout:
mkdir -p notes-server/src/main/java/com/example/notes notes-server/src/main/resources
cd notes-serverCreate pom.xml. It imports the SDK's bill of materials, depends on the mcp convenience module, which bundles the core SDK with Jackson, and builds one runnable jar with the shade plugin:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>notes-server</artifactId>
<version>1.0.0</version>
<properties>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>io.modelcontextprotocol.sdk</groupId>
<artifactId>mcp-bom</artifactId>
<version>2.0.1</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>io.modelcontextprotocol.sdk</groupId>
<artifactId>mcp</artifactId>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>2.0.17</version>
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.5.38</version>
</dependency>
</dependencies>
<build>
<finalName>notes-server</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.6.2</version>
<executions>
<execution>
<phase>package</phase>
<goals><goal>shade</goal></goals>
<configuration>
<createDependencyReducedPom>false</createDependencyReducedPom>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>com.example.notes.NotesServer</mainClass>
</transformer>
</transformers>
<filters>
<filter>
<artifact>*:*</artifact>
<excludes>
<exclude>META-INF/*.SF</exclude>
<exclude>META-INF/*.DSA</exclude>
<exclude>META-INF/*.RSA</exclude>
</excludes>
</filter>
</filters>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>The BOM pins every SDK and transitive dependency version, so you state mcp-bom's version once. slf4j-api and logback-classic give the SDK somewhere to log. The shade plugin writes target/notes-server.jar with every dependency inside it and NotesServer as its main class. Its ServicesResourceTransformer merges the META-INF/services files that the dependencies each ship, so lookups that use them still work from the single jar.
Logback needs a config file here. Without one it logs at DEBUG to standard output, which a stdio server uses for the protocol, and the client in step 3 timed out after 20 seconds waiting for initialize. Create src/main/resources/logback.xml to send logs to standard error:
<configuration>
<appender name="STDERR" class="ch.qos.logback.core.ConsoleAppender">
<target>System.err</target>
<encoder>
<pattern>%d{HH:mm:ss.SSS} %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="STDERR"/>
</root>
</configuration>Check the SDK version resolved:
mvn dependency:tree -Dincludes=io.modelcontextprotocol.sdk:mcpAmong Maven's other log lines, that printed:
[INFO] com.example:notes-server:jar:1.0.0
[INFO] \- io.modelcontextprotocol.sdk:mcp:jar:2.0.1:compile-Dincludes filters the tree to the one artifact. Leave out -q here, because the tree is printed as log output and -q hides it.
Step 2: Write the tool schemas by hand
Unlike the Python, TypeScript or Go SDKs, the Java SDK does not generate a tool's schema from a method signature. You build the JSON Schema yourself and hand it to Tool.builder. Create src/main/java/com/example/notes/NotesServer.java:
package com.example.notes;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import io.modelcontextprotocol.json.McpJsonDefaults;
import io.modelcontextprotocol.server.McpServer;
import io.modelcontextprotocol.server.McpServerFeatures.SyncToolSpecification;
import io.modelcontextprotocol.server.McpSyncServer;
import io.modelcontextprotocol.server.transport.StdioServerTransportProvider;
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities;
import io.modelcontextprotocol.spec.McpSchema.TextContent;
import io.modelcontextprotocol.spec.McpSchema.Tool;
import io.modelcontextprotocol.spec.McpSchema.ToolAnnotations;
public final class NotesServer {
private static final Map<String, Object> NO_ARGS_SCHEMA = Map.of(
"type", "object", "properties", Map.of(), "additionalProperties", false);
private static final Map<String, Object> TITLE_SCHEMA = Map.of(
"type", "object", "properties",
Map.of("title", Map.of("type", "string", "description", "The note's title")),
"required", List.of("title"), "additionalProperties", false);
private static final Map<String, Object> ADD_NOTE_SCHEMA = Map.of(
"type", "object", "properties",
Map.of("title", Map.of("type", "string", "description", "The note's title"),
"body", Map.of("type", "string", "description", "The note's text")),
"required", List.of("title", "body"), "additionalProperties", false);
private final Path notesDir;
public NotesServer(Path notesDir) {
this.notesDir = notesDir;
}
/** Maps a title to a file inside notesDir, and refuses anything that would escape it. */
Path notePath(String title) {
String slug = title.toLowerCase().replaceAll("[^a-z0-9-]+", "-").replaceAll("^-+|-+quot;, "");
if (slug.isEmpty()) {
throw new ToolInputException("A note needs a title with at least one letter or number.");
}
return notesDir.resolve(slug + ".md");
}
List<String> listNotes() {
try (var stream = Files.list(notesDir)) {
return stream.filter(p -> p.toString().endsWith(".md"))
.map(p -> p.getFileName().toString().replace(".md", ""))
.sorted()
.collect(Collectors.toList());
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
String readNote(String title) {
Path path = notePath(title);
if (!Files.exists(path)) {
throw new ToolInputException("No note called '" + title + "'.");
}
try {
return Files.readString(path);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
String addNote(String title, String body) {
Path path = notePath(title);
if (Files.exists(path)) {
throw new ToolInputException("A note called '" + title + "' already exists.");
}
try {
Files.writeString(path, body);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
return "Saved " + path.getFileName().toString().replace(".md", ".");
}
/** A failure the model can read and correct. Caught and turned into isError(true). */
static final class ToolInputException extends RuntimeException {
ToolInputException(String message) {
super(message);
}
}
List<SyncToolSpecification> toolSpecifications() {
return List.of(
SyncToolSpecification.builder()
.tool(Tool.builder("list_notes", NO_ARGS_SCHEMA)
.description("List the titles of every saved note.")
.annotations(ToolAnnotations.builder().readOnlyHint(true).build())
.build())
.callHandler((exchange, request) -> callHandler(() -> {
List<String> titles = listNotes();
return CallToolResult.builder()
.content(List.of(TextContent.builder(titles.toString()).build()))
.structuredContent(Map.of("result", titles))
.build();
}))
.build(),
SyncToolSpecification.builder()
.tool(Tool.builder("read_note", TITLE_SCHEMA)
.description("Return the text of one note.")
.annotations(ToolAnnotations.builder().readOnlyHint(true).build())
.build())
.callHandler((exchange, request) -> callHandler(() -> {
String title = (String) request.arguments().get("title");
String text = readNote(title);
return CallToolResult.builder()
.content(List.of(TextContent.builder(text).build()))
.structuredContent(Map.of("result", text))
.build();
}))
.build(),
SyncToolSpecification.builder()
.tool(Tool.builder("add_note", ADD_NOTE_SCHEMA)
.description("Save a note. Fails if a note with that title already exists.")
.annotations(ToolAnnotations.builder().readOnlyHint(false).destructiveHint(false).build())
.build())
.callHandler((exchange, request) -> callHandler(() -> {
String title = (String) request.arguments().get("title");
String body = (String) request.arguments().get("body");
String message = addNote(title, body);
return CallToolResult.builder()
.content(List.of(TextContent.builder(message).build()))
.structuredContent(Map.of("result", message))
.build();
}))
.build());
}
private static CallToolResult callHandler(java.util.function.Supplier<CallToolResult> body) {
try {
return body.get();
} catch (ToolInputException e) {
return CallToolResult.builder()
.content(List.of(TextContent.builder(e.getMessage()).build()))
.isError(true)
.build();
}
}
public static void main(String[] args) throws IOException {
Path notesDir = Path.of(System.getenv().getOrDefault("NOTES_DIR", "notes")).toAbsolutePath();
Files.createDirectories(notesDir);
NotesServer notes = new NotesServer(notesDir);
var transportProvider = new StdioServerTransportProvider(McpJsonDefaults.getMapper());
McpSyncServer server = McpServer.sync(transportProvider)
.serverInfo("notes", "1.0.0")
.capabilities(ServerCapabilities.builder().tools(true).build())
.tools(notes.toolSpecifications())
.build();
Runtime.getRuntime().addShutdownHook(new Thread(server::close));
}
}Tool.builder(name, inputSchema) takes a raw JSON Schema map. There is no reflection-based schema inference the way there is in the Python or Go SDKs, so a tool with two string fields means writing out type, properties and required by hand. The SDK validates every inputSchema against the JSON Schema 2020-12 meta-schema at build time, so a malformed schema fails fast with an IllegalArgumentException.
The server validates incoming arguments for you. Before callHandler runs, the SDK checks the arguments against inputSchema. Calling read_note with no title never reaches readNote. It comes back as a CallToolResult with isError(true) and a schema validation message. .validateToolInputs(false) turns this off, but the default catches bugs before your code sees them.
Throw an exception for failures you expect, and catch it yourself. A CallToolResult with isError(true) reaches the model as a response it can read and retry from. Anything that escapes the handler uncaught becomes a JSON-RPC protocol error instead. The SDK has no ToolError type the framework converts for you, so ToolInputException is a small helper each handler catches through callHandler, instead of leaving the client with a generic "Internal error."
Annotations are hints. readOnlyHint(true) tells clients the tool changes nothing, which a client can use when deciding what to confirm with you. It enforces nothing, and a tool marked read-only can still delete files if its code does.
StdioServerTransportProvider serves over stdin and stdout, what local clients like Claude Code launch. Standard output carries the protocol there, so logging to it breaks the connection. The logback.xml from step 1 keeps the SDK's logs on standard error.
Step 3: Test it with a client
The same mcp module includes a client. Put it in its own Maven project in a folder next to notes-server. From notes-server:
mkdir -p ../notes-client/src/main/java/com/example/notesclient../notes-client/pom.xml is the server's pom.xml with notes-client as the artifactId, only the mcp dependency, and no <build> section. Create ../notes-client/src/main/java/com/example/notesclient/NotesClient.java:
package com.example.notesclient;
import java.util.Map;
import io.modelcontextprotocol.client.McpClient;
import io.modelcontextprotocol.client.McpSyncClient;
import io.modelcontextprotocol.client.transport.ServerParameters;
import io.modelcontextprotocol.client.transport.StdioClientTransport;
import io.modelcontextprotocol.json.McpJsonDefaults;
import io.modelcontextprotocol.spec.McpSchema.CallToolRequest;
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
import io.modelcontextprotocol.spec.McpSchema.InitializeResult;
import io.modelcontextprotocol.spec.McpSchema.ListToolsResult;
import io.modelcontextprotocol.spec.McpSchema.TextContent;
public class NotesClient {
public static void main(String[] args) {
ServerParameters params = ServerParameters.builder("java")
.args("-jar", "../notes-server/target/notes-server.jar")
.build();
var transport = new StdioClientTransport(params, McpJsonDefaults.getMapper());
McpSyncClient client = McpClient.sync(transport).build();
InitializeResult init = client.initialize();
System.out.println("protocol version: " + init.protocolVersion());
ListToolsResult tools = client.listTools();
System.out.println(tools.tools().stream().map(t -> t.name()).toList());
CallToolResult added = client.callTool(CallToolRequest.builder("add_note")
.arguments(Map.of("title", "Release checklist", "body", "Tag, build, announce."))
.build());
System.out.println(((TextContent) added.content().get(0)).text());
CallToolResult listed = client.callTool(
CallToolRequest.builder("list_notes").arguments(Map.of()).build());
System.out.println(listed.structuredContent());
CallToolResult note = client.callTool(CallToolRequest.builder("read_note")
.arguments(Map.of("title", "release checklist"))
.build());
System.out.println(note.structuredContent());
client.closeGracefully();
}
}The client launches ../notes-server/target/notes-server.jar, so build that jar first, then compile and run the client from its own folder:
mvn -q package
cd ../notes-client
mvn -q compile
java -cp "target/classes:$(mvn -q dependency:build-classpath -Dmdep.outputFile=/dev/stdout)" com.example.notesclient.NotesClientprotocol version: 2025-11-25
[list_notes, read_note, add_note]
Saved release-checklist.
{result=[release-checklist]}
{result=Tag, build, announce.}The server runs in the client's working directory, so the note lands in notes-client/notes/. That output is from a first run with no notes folder. Run it again and add_note reports that the note already exists, so rm -rf notes to start over. The client project has no SLF4J binding, so it also prints three SLF4J(W) warnings to standard error.
listTools() came back in the order the tools were added, not alphabetically. The SDK keeps a server's tools as the list you built. During initialize(), the client sends the protocol versions its transport understands, newest first. The built-in StdioClientTransport advertises 2024-11-05, 2025-03-26, 2025-06-18 and 2025-11-25, and this run settled on 2025-11-25 through the classic initialize/notifications/initialized handshake. ServerParameters launches java -jar ... as a subprocess, the same way Claude Code will.
Step 4: Serve it over Streamable HTTP inside embedded Tomcat
The core SDK's HTTP transport is a jakarta.servlet.Servlet, not a standalone listener, so you host it in a servlet container yourself. Add jakarta.servlet-api and an embeddable container to the server's <dependencies>. This server uses Tomcat, the same choice the SDK's own conformance tests make:
<dependency>
<groupId>jakarta.servlet</groupId>
<artifactId>jakarta.servlet-api</artifactId>
<version>6.1.0</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-core</artifactId>
<version>11.0.27</version>
</dependency>Create src/main/java/com/example/notes/NotesHttpServer.java, reusing the NotesServer tool specifications:
package com.example.notes;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.List;
import org.apache.catalina.Context;
import org.apache.catalina.startup.Tomcat;
import io.modelcontextprotocol.server.McpServer;
import io.modelcontextprotocol.server.McpSyncServer;
import io.modelcontextprotocol.server.transport.DefaultServerTransportSecurityValidator;
import io.modelcontextprotocol.server.transport.HttpServletStreamableServerTransportProvider;
import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities;
public final class NotesHttpServer {
public static void main(String[] args) throws Exception {
Path notesDir = Path.of(System.getenv().getOrDefault("NOTES_DIR", "notes")).toAbsolutePath();
Files.createDirectories(notesDir);
NotesServer notes = new NotesServer(notesDir);
// Refuse requests whose Host or Origin header is not this machine.
var securityValidator = DefaultServerTransportSecurityValidator.builder()
.allowedHosts(List.of("localhost:*", "127.0.0.1:*"))
.allowedOrigins(List.of("http://localhost:*", "http://127.0.0.1:*"))
.build();
var transportProvider = HttpServletStreamableServerTransportProvider.builder()
.mcpEndpoint("/mcp")
.keepAliveInterval(Duration.ofSeconds(30))
.securityValidator(securityValidator)
.build();
McpSyncServer server = McpServer.sync(transportProvider)
.serverInfo("notes", "1.0.0")
.capabilities(ServerCapabilities.builder().tools(true).build())
.tools(notes.toolSpecifications())
.build();
// Embed Tomcat and register transportProvider as a servlet, bound to
// 127.0.0.1:8080, the same wiring the SDK's own conformance server uses.
Tomcat tomcat = new Tomcat();
tomcat.setPort(8080);
tomcat.getConnector().setProperty("address", "127.0.0.1");
Context context = tomcat.addContext("", System.getProperty("java.io.tmpdir"));
var wrapper = context.createWrapper();
wrapper.setName("mcpServlet");
wrapper.setServlet(transportProvider);
wrapper.setAsyncSupported(true);
context.addChild(wrapper);
context.addServletMappingDecoded("/*", "mcpServlet");
tomcat.start();
Runtime.getRuntime().addShutdownHook(new Thread(server::close));
tomcat.getServer().await();
}
}Rebuild the jar and start the HTTP server from the notes-server folder:
cd ../notes-server
mvn -q package
java -cp target/notes-server.jar com.example.notes.NotesHttpServerNotes from this server land in notes-server/notes/. In a second terminal, drive it with raw JSON-RPC over HTTP. initialize returns a session ID in the Mcp-Session-Id response header:
curl -s -D - -o /dev/null -X POST http://127.0.0.1:8080/mcp \
-H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"curl-test","version":"0.0.1"}}}'HTTP/1.1 200
Mcp-Session-Id: 03e15d35-1007-45be-9e48-8047f0d25e1f
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
Date: Thu, 15 Oct 2026 21:09:29 GMTEvery later request on that session sends the ID back, along with the MCP-Protocol-Version header. A small shell function keeps the commands short. Send notifications/initialized first, then save a note, save it again, and try to read a path outside the folder:
SID=03e15d35-1007-45be-9e48-8047f0d25e1f # the Mcp-Session-Id you got back
mcp() {
curl -s -X POST http://127.0.0.1:8080/mcp \
-H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \
-H "Mcp-Session-Id: $SID" -H "MCP-Protocol-Version: 2025-11-25" \
-d "$1"
}
mcp '{"jsonrpc":"2.0","method":"notifications/initialized"}'
mcp '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"add_note","arguments":{"title":"Release checklist","body":"Tag, build, announce."}}}'
mcp '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"add_note","arguments":{"title":"Release checklist","body":"again"}}}'
mcp '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"read_note","arguments":{"title":"../../etc/passwd"}}}'The notification gets an empty 202. Each tool call comes back as a server-sent event:
id: 03e15d35-1007-45be-9e48-8047f0d25e1f
event: message
data: {"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"Saved release-checklist."}],"isError":false,"structuredContent":{"result":"Saved release-checklist."}}}
id: 03e15d35-1007-45be-9e48-8047f0d25e1f
event: message
data: {"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"A note called 'Release checklist' already exists."}],"isError":true}}
id: 03e15d35-1007-45be-9e48-8047f0d25e1f
event: message
data: {"jsonrpc":"2.0","id":4,"result":{"content":[{"type":"text","text":"No note called '../../etc/passwd'."}],"isError":true}}The duplicate came back as the ToolInputException message. The path that tried to climb out of the folder found nothing, because it was looked up as etc-passwd.md inside it. A request without the Mcp-Session-Id header gets a 400 with Session ID required in mcp-session-id header.
The transport checks no Host or Origin header unless you give it a validator. The securityValidator builder option defaults to ServerTransportSecurityValidator.NOOP, which checks nothing. Without the validator in NotesHttpServer, a request with -H "Host: evil.example.com" still returned 200. With it, the same request is refused, and so is one with a foreign Origin:
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:8080/mcp \
-H "Host: evil.example.com" \
-H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"curl-test","version":"0.0.1"}}}'421With -H "Origin: http://evil.example.com" in place of the Host header it returned 403. The validator lists 127.0.0.1:* next to localhost:* because these curls connect to 127.0.0.1. With localhost:* alone, it would refuse them too.
The server binds to 127.0.0.1, so only your machine can reach it. The validator does not check who is calling, so put authentication in front of the server before it goes anywhere public, or it will run its tools for anyone who finds the URL.
Step 5: Add it to Claude Code
From the notes-server folder, register the stdio version for this project:
claude mcp add --scope project notes -- java -jar "$PWD/target/notes-server.jar"That writes a .mcp.json file in the folder:
{
"mcpServers": {
"notes": {
"type": "stdio",
"command": "java",
"args": ["-jar", "/path/to/notes-server/target/notes-server.jar"],
"env": {}
}
}
}claude mcp get notes shows it as pending approval. Claude Code asks before running a project server, because .mcp.json gets committed and cloning a repository shouldn't run a command on your machine. Start claude in the folder, approve it, and ask it to save a note.
To use the HTTP version instead, with the server from step 4 running:
claude mcp add --scope project --transport http notes http://127.0.0.1:8080/mcpClaude Code reads its servers when a session starts, so open a new session after adding one.
Step 6: Put it behind Statio
Statio is a gateway that grants tools one at a time, so agents that look things up can get list_notes and read_note while only the agent that writes gets add_note. Connect the Tomcat version by URL on any plan, or from the Team plan up give Statio a Docker image built around notes-server.jar.
The setup is in Connect a client and Permissions, and MCP permissions compares the other ways to limit an agent's tools.
Conclusion
You now have a Java MCP server running over stdio or inside embedded Tomcat. Writing each schema by hand costs some typing, and it buys you a check before every call: a missing or mistyped argument never reaches your handler. That leaves the handlers to deal with failures a schema cannot express, like a title that already exists.
Where to go next:
- How to build an MCP server in Python, the same server with the Python SDK.
- How to build an MCP server in TypeScript, the same server with the TypeScript SDK.
- How to turn an OpenAPI spec into an MCP server, when the API already exists and you would rather not write the tools by hand.
- How to add MCP servers to Claude Code and how to set up MCP servers in Cursor, for the client side in more depth.
- How to give an AI agent read-only GitHub access, which applies the same read and write split to a server you did not build.