Skip to main content

Class 19: Plugging Into Claude Desktop via stdio

Duration: 40 minutes | Difficulty: Intermediate | Prerequisites: Any Track B class (Class 6 onwards). Claude Desktop installed.


Companion code

This lesson carries on from Class 18. If you followed along, keep working on the project you already have. If you skipped it, clone the mcp_class_18 branch to start from the same place:

git clone --branch mcp_class_18 https://github.com/graphqlguy/graphQLMovieDB-MCP.git

This is an extra hands-on lesson for Track B, after Class 18 finished the main part. By now you have a streamable-HTTP MCP server with OAuth protection, an agent client app and observability. Most of that is for production. The local Claude Desktop integration is for your own machine, and it lets you use your tools in a client you already work with. In this lesson we switch the starter to the stdio transport, write a launcher script and add a JSON block to Claude Desktop's configuration file. Then your moviedb tools appear in Claude's UI like any official MCP server.

This lesson uses a few terms that may be new. Each one gets a fuller explanation where the lesson puts it to work:

  • stdio transport is the MCP transport where the client starts the server as a subprocess, and the two exchange messages over the process's standard input and output.
  • JSON-RPC is the request and response message format that MCP uses on every transport.
  • Spring profile is a named set of configuration, here stdio, that Spring Boot applies when the profile is active.
  • Logback appender is the part of a Logback configuration that decides where log lines go, such as stdout or stderr.
  • SecurityContext is where Spring Security keeps the identity of the current caller, which @PreAuthorize checks read.
  • MCPB Desktop Extension is a local MCP server packaged as one .mcpb bundle that Claude Desktop installs without JSON editing.
  • jpackage is the JDK tool that packages a Java application together with its own runtime.

Why stdio​

Streamable HTTP is the right transport for hosted MCP servers. Stdio is the right transport for local tools. Claude Desktop, Claude Code and most developer tools that run on your own machine start the MCP server as a subprocess, and exchange newline-delimited JSON-RPC messages with it over stdin and stdout. Because the server is a local subprocess, the setup does not need a network connection, authentication or a port.

Stdio has clear trade-offs:

  • The Desktop app starts stdio servers when it starts, which is why a configuration change needs an app restart.
  • A stdio server serves a single user.
  • It does not need OAuth, because the operating system is the trust boundary. The subprocess runs with the user's privileges, and those privileges limit what it can do.
  • It does not scale. Each running app instance has its own server process, so this transport is unsuitable for multi-tenant deployments.

Claude Desktop has two newer options that you should know about before you switch to stdio. First, Claude Desktop supports remote MCP servers via custom connectors, which you add in the app's settings. Those connections come from Anthropic's cloud, so the streamable-HTTP server from Class 17 would work without a transport change, as long as it is reachable from the internet. Second, to give a local server to people who are not developers, one-click MCPB Desktop Extensions package it as an .mcpb bundle, formerly .dxt, so users can install it without editing JSON. A developer working on their own machine can edit the configuration JSON by hand, and this lesson takes that path.

For personal tools, developer workflows and any MCP server you want on your own laptop, stdio is the right choice.

Switching the Starter to stdio​

The Spring AI MCP starter already supports stdio. You need two changes: configuration, and a startup without the web container. The simplest way is a profile just for stdio mode:

src/main/resources/application-stdio.yaml
# Class 19: stdio transport profile for local clients (Claude Desktop, Claude
# Code). Activate with --spring.profiles.active=stdio. web-application-type none
# binds no ports so the only IO is stdin/stdout; banner-mode off keeps the Spring
# banner off stdout, which is the JSON-RPC protocol channel and must stay clean.
# stdio: true enables the stdio transport (a separate property from protocol).
# show-sql stays off here so Hibernate never writes SQL to stdout.
spring:
main:
web-application-type: none
banner-mode: off
jpa:
show-sql: false
ai:
mcp:
server:
name: moviedb-mcp-stdio
version: 1.0.0
type: SYNC
stdio: true
capabilities:
tool: true

This profile differs from the production HTTP profile in four ways:

  • web-application-type: none turns off the embedded web container. The JVM does not open any ports, and all its input and output go through stdin and stdout.
  • banner-mode: off hides the Spring banner.
  • show-sql: false repeats the project's default, so Hibernate keeps SQL off stdout in this profile even if you turn the setting on elsewhere while debugging.
  • spring.ai.mcp.server.stdio: true turns on the stdio transport. It is a separate property from protocol.
stdout is the protocol channel

Stray output on stdout is the most common reason a stdio MCP server fails. The protocol sends its JSON-RPC messages over stdout, so anything else written to stdout corrupts the messages, and the host disconnects with only a generic error message. Unwanted stdout output comes from several sources:

  • the Spring banner, which banner-mode: off turns off

  • the default Logback console appender, which writes formatted text that is not JSON to stdout

  • stray System.out.println(...) calls in your code or in transitive dependencies

  • Hibernate's show-sql: true output, if you turned it on for debugging (the Movie Database app ships with it off)

  • the DataInitializer's seeding log lines (Seeding database with movies, actors, directors, and TV shows...), which reach stdout through the same default console appender

The fix has three parts: send every logger to stderr with the logback-stdio.xml shown below, search your code for System.out, and turn show-sql off in the stdio profile. If Claude Desktop reports only that the server disconnected, check stdout first.

Turning the web container off also breaks two configuration classes. SecurityConfig and McpSecurityConfig both build a SecurityFilterChain from HttpSecurity, and HttpSecurity exists only in a web application context. So in stdio mode, the application fails at startup because that bean is missing. Both classes are therefore limited to the other profiles:

src/main/java/com/graphqlguy/moviedb/config/SecurityConfig.java (the lines added above @Configuration)
// Class 19: gated off in stdio mode, where no web container runs and the
// HttpSecurity bean this chain needs does not exist.
@Profile("!stdio")
src/main/java/com/graphqlguy/moviedb/security/McpSecurityConfig.java (the lines added above @Configuration)
// Class 19: gated off in stdio mode alongside SecurityConfig.
@Profile("!stdio")

Both need import org.springframework.context.annotation.Profile;. In stdio mode the HTTP security chain is unnecessary, because the transport is a pipe on your own machine. The server does not open a port or check bearer tokens, and the only caller is the Claude Desktop process that started the JVM. Class 15's OAuth setup still protects the HTTP profile, which is the one exposed to a network.

The complete McpSecurityConfig.java, collapsed because it runs to 77 lines
src/main/java/com/graphqlguy/moviedb/security/McpSecurityConfig.java
package com.graphqlguy.moviedb.security;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;

import java.util.List;

/**
* Class 15: a second security filter chain, scoped by {@code securityMatcher}
* to the MCP paths and ordered ahead of the prerequisite tutorial's HMAC chain.
* It makes /mcp an OAuth 2.1 resource server: bearer JWTs validated against the
* configured issuer's JWK Set, while the discovery endpoints stay public. The
* two chains never contend because this one matches only /mcp and the
* well-known paths; everything else falls through to the default chain.
*/
// Class 19: gated off in stdio mode alongside SecurityConfig.
@Profile("!stdio")
@Configuration
public class McpSecurityConfig {

@Value("${moviedb.mcp.resource}")
private String resource;

@Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}")
private String issuer;

@Bean
@Order(1)
SecurityFilterChain mcpFilterChain(HttpSecurity http) throws Exception {
// RFC 9728 section 5.1: a 401 from a protected resource should carry a
// WWW-Authenticate header with a resource_metadata parameter, which
// tells a client without a token where the metadata document is.
// Spring Security 7's default BearerTokenAuthenticationEntryPoint adds
// that parameter itself, built from the address of the request, so the
// chain keeps the default.
http
// /mcp is a stateless bearer-token API, so both of these matter.
// CsrfFilter would otherwise reject every POST without a token,
// and an MCP streamable-HTTP client's very first request is a POST
// `initialize`: the client would get a 403 with no
// WWW-Authenticate header and no way to discover what it needs.
// The default chain disables both for the same reason.
.csrf(csrf -> csrf.disable())
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.securityMatcher("/mcp", "/mcp/**",
"/.well-known/oauth-protected-resource",
"/.well-known/oauth-protected-resource/mcp")
.authorizeHttpRequests(auth -> auth
.requestMatchers("/.well-known/oauth-protected-resource",
"/.well-known/oauth-protected-resource/mcp").permitAll()
.anyRequest().authenticated())
.oauth2ResourceServer(oauth -> oauth
.jwt(jwt -> {})
// Spring Security serves the RFC 9728 document itself, from a
// filter that runs ahead of the DispatcherServlet. Describing
// the resource here is the only way to change that document. A
// @RestController mapped to the same paths is bypassed, because
// the filter answers those requests first.
.protectedResourceMetadata(metadata -> metadata
.protectedResourceMetadataCustomizer(builder -> builder
.resource(resource)
.authorizationServer(issuer)
.scopes(scopes -> scopes.addAll(List.of(
"movies:read", "watchlist:read",
"watchlist:write", "reviews:read")))
.claim("resource_documentation",
"https://graphqlguy.com/docs/tutorial-graphql-mcp"))));
return http.build();
}
}

Any application code that writes to stdout outside the JSON-RPC messages breaks the stdio transport. So when this profile is active, Logback should send everything to stderr:

src/main/resources/logback-stdio.xml
<?xml version="1.0" encoding="UTF-8"?>
<!--
Class 19: for the stdio profile only. stdout carries the JSON-RPC framing, so
every log line must go to stderr instead. Activate with
-Dlogging.config=classpath:logback-stdio.xml when running the stdio profile;
leave the production logback-spring.xml untouched for HTTP mode.
-->
<configuration>
<appender name="STDERR" class="ch.qos.logback.core.ConsoleAppender">
<target>System.err</target>
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="STDERR"/>
</root>
</configuration>

Use it by passing -Dlogging.config=classpath:logback-stdio.xml when you run with the stdio profile. Leave the existing Logback configuration for production HTTP mode as it is.

Reconciling with Class 15's Security​

If you followed the course in order, your server includes Class 15's OAuth setup, and switching to stdio conflicts with it in two ways. Without the changes in this section, the server either fails to start or answers every tool call with an authorization error, so this section is required.

The first conflict happens at startup. The security filter chains from the Spring GraphQL tutorial and Class 15 (SecurityConfig.securityFilterChain and McpSecurityConfig.mcpFilterChain) are built on HttpSecurity, a bean that exists only when a web container runs. With web-application-type: none, the stdio profile fails at startup with Parameter 0 of method securityFilterChain ... required a bean of type 'HttpSecurity' that could not be found. We reproduced this by running the unmodified server exactly as this lesson starts it. The @Profile("!stdio") annotations added in Switching the Starter to stdio already prevent this failure.

Gating SecurityConfig also removes one bean that the stdio profile still needs. DataInitializer seeds user accounts in every profile, and it uses a PasswordEncoder to do that. The encoder bean is declared inside SecurityConfig, so turning that class off leaves a missing dependency at startup. Move the bean to its own class without a profile, and delete it from SecurityConfig:

src/main/java/com/graphqlguy/moviedb/config/PasswordEncoderConfig.java
package com.graphqlguy.moviedb.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

// Unconditional on purpose: DataInitializer needs the encoder in every
// profile, including stdio where the web security chains are switched off.
@Configuration
public class PasswordEncoderConfig {

@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}

The same problem appears in general: a @Configuration class that holds both web security setup and a plain utility bean cannot be limited to a profile as a whole. The fix is to split it.

With the encoder moved out, SecurityConfig holds only the web security chain:

The complete SecurityConfig.java, collapsed because it runs to 37 lines
src/main/java/com/graphqlguy/moviedb/config/SecurityConfig.java
package com.graphqlguy.moviedb.config;

import com.graphqlguy.moviedb.security.JwtAuthFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

// Class 19: gated off in stdio mode, where no web container runs and the
// HttpSecurity bean this chain needs does not exist.
@Profile("!stdio")
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {

private final JwtAuthFilter jwtAuthFilter;

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
//CSRF is disabled because our API is stateless. CSRF protection guards against attacks where a malicious website submits forms to
// your server using the victim's session cookie. Since we use JWT tokens (not cookies) and do not have sessions, CSRF attacks aren't possible against our API.
.csrf(csrf -> csrf.disable())
//Session management is STATELESS because JWT tokens carry all authentication information. The server doesn't need to remember sessions between requests,
// which is simpler and scales better. Any server in a cluster can validate a JWT without consulting a shared session store.
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
//All requests are permitted at the HTTP level. This might seem counterintuitive - why have security if everything is allowed? The answer is that we handle
// authorization at the method level using @PreAuthorize, not at the URL level. GraphQL has one endpoint, and we need unauthenticated users to reach it for public queries like movies.
.authorizeHttpRequests(auth -> auth.anyRequest().permitAll())
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}

The second conflict is harder to notice. @PreAuthorize works the same on every transport. It reads the SecurityContext, and under stdio no filter chain fills one in. With only the profile gates above, the server starts and registers its tools, but every guarded tool call returns isError: true with An Authentication object was not found in the SecurityContext. Remember the point from the Why stdio section: the operating system is the trust boundary of a stdio server, and the process serves a single user. The OAuth setup is inactive here, because no web container runs. So give the whole JVM one fixed local identity with the Class 15 scopes:

src/main/java/com/graphqlguy/moviedb/mcp/StdioSecurityConfig.java
package com.graphqlguy.moviedb.mcp;

import com.graphqlguy.moviedb.user.AppUser;
import com.graphqlguy.moviedb.user.Role;
import com.graphqlguy.moviedb.user.UserRepository;
import jakarta.annotation.PostConstruct;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.core.annotation.Order;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.password.PasswordEncoder;

@Profile("stdio")
@Configuration
public class StdioSecurityConfig {

static final String LOCAL_USERNAME = "desktop-user";

// A stdio server is one subprocess belonging to one OS user. MODE_GLOBAL
// shares a single security context across all threads, so the scope checks
// from Class 15 and the user-scoped watchlist logic keep working against
// one fixed local principal.
@PostConstruct
void seedLocalIdentity() {
SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_GLOBAL);
var authentication = new UsernamePasswordAuthenticationToken(
LOCAL_USERNAME, "n/a",
AuthorityUtils.createAuthorityList(
"SCOPE_movies:read", "SCOPE_reviews:read",
"SCOPE_watchlist:read", "SCOPE_watchlist:write"));
SecurityContextHolder.getContext().setAuthentication(authentication);
}

// Authenticating as a name that is missing from the users table gets you past
// every scope check and then fails at the first user-scoped operation:
// WatchlistService resolves the principal to an AppUser row and refuses
// when there is none. A fixed identity is still an identity, so it needs
// an account like any other. @Order(1) runs this after DataInitializer's
// unordered runner, which is what creates the schema's other users.
@Bean
@Order(1)
CommandLineRunner ensureLocalAccount(UserRepository users, PasswordEncoder passwordEncoder) {
return args -> {
if (users.findByUsername(LOCAL_USERNAME).isEmpty()) {
users.save(AppUser.builder()
.username(LOCAL_USERNAME)
.email("desktop-user@localhost")
.password(passwordEncoder.encode("n/a"))
.role(Role.USER)
.build());
}
};
}
}

The second bean is easy to forget, and forgetting it causes confusing failures. Authentication and identity are separate here. The UsernamePasswordAuthenticationToken above passes every @PreAuthorize check in the codebase, because those checks look at authorities. It fails on the user-scoped service paths, because those look up the principal's name as an AppUser row. DataInitializer seeds user, mara, admin and the other accounts, and desktop-user is not among them. Without that account, recommendMoviesForMood works, but addToWatchlist returns the tool error GraphQL operation failed: Entity AppUser not found for username: desktop-user. When read tools work and write tools fail like this, the account is missing.

With all three pieces in place, the @PreAuthorize rules from Class 15 check the local identity and pass, and the watchlist operations work on a real desktop-user row. The HTTP profile's OAuth setup stays as it was, because everything here applies only to the stdio profile. A simpler alternative also starts: put @EnableMethodSecurity behind @Profile("!stdio"), so the checks are skipped entirely. That leaves the user-scoped service paths without a principal, so the fixed-identity approach is better, because it keeps the whole stack working as designed.

Building the Jar and Writing the Launcher Script​

Every earlier lesson ran the server with ./mvnw spring-boot:run, so your target/ directory contains old classes or is empty. The launcher below runs the packaged jar, so build it first. Rebuild after any change to the stdio profile, the security configuration above or the Logback file, because all three are packaged into the jar:

./mvnw clean package

Claude Desktop starts your server with a single command. The simplest way is a shell script that runs the JVM:

bin/moviedb-mcp-stdio.sh
#!/usr/bin/env bash
set -euo pipefail

# Class 19: Claude Desktop and Claude Code start this script as a subprocess and
# exchange JSON-RPC messages with it over stdin and stdout. exec replaces this
# shell with the JVM, so no wrapper process stays behind. set -euo pipefail stops
# the script at the first error, so the host sees a clear failure and the
# connection does not hang. logback-stdio.xml sends all logs to stderr, and the
# stdio profile turns off the web container and the banner.
#
# Edit both of these before the first run. They are this machine's paths,
# not a convention: JAR_PATH is wherever you cloned the project, and
# JAVA_HOME must point at a JDK 21 or newer that actually exists here.
JAR_PATH="${HOME}/projects/moviedb/target/moviedb-0.0.1-SNAPSHOT.jar"
JAVA_HOME="${JAVA_HOME:-/Library/Java/JavaVirtualMachines/jdk-21.jdk/Contents/Home}"

exec "${JAVA_HOME}/bin/java" \
-Dlogging.config=classpath:logback-stdio.xml \
-jar "${JAR_PATH}" \
--spring.profiles.active=stdio

exec replaces the script's shell with the JVM, so no extra shell process stays between Claude Desktop and the JVM. set -euo pipefail makes the script stop at the first error, so Claude Desktop sees a clear failure and the connection does not hang.

Check the JAVA_HOME fallback carefully, because it is the line most likely to break your first run. Claude Desktop starts the script without a login shell, so your .zshrc does not run and JAVA_HOME is usually unset. The script then uses the fallback path, and on a machine that uses SDKMAN, Homebrew or any per-user JDK, that path does not exist. Run /usr/libexec/java_home -v 21 in your terminal to get the real path, and paste it in. If you skip this, Claude Desktop's server log shows only no such file or directory, and the message does not mention Java.

In production, you could package the JAR with jpackage and skip the script, which removes both of these path settings.

Make it executable: chmod +x bin/moviedb-mcp-stdio.sh.

claude_desktop_config.json​

Claude Desktop reads its MCP server list from a JSON file. On macOS the default path is ~/Library/Application Support/Claude/claude_desktop_config.json; on Windows it is %APPDATA%\Claude\claude_desktop_config.json. Add a mcpServers block:

~/Library/Application Support/Claude/claude_desktop_config.json
{
"mcpServers": {
"moviedb": {
"command": "/Users/yourname/projects/moviedb/bin/moviedb-mcp-stdio.sh",
"args": [],
"env": {
"SPRING_DATASOURCE_URL": "jdbc:h2:file:~/.moviedb/moviedb",
"SPRING_JPA_HIBERNATE_DDL_AUTO": "update"
}
}
}
}

The entry has three parts:

  • command is the absolute path to the launcher script.
  • args are command-line arguments for the launcher. The list is empty, because the script already sets the profile.
  • env is the environment of the started process, where you can override Spring properties with SPRING_* variables.

Every value in env is a literal string. Claude Desktop does not expand ${VAR} references, so anything dynamic belongs in the launcher script, which runs in a real shell. The server also does not need an ANTHROPIC_API_KEY, because Claude Desktop provides the model and your process only serves tools.

The two SPRING_* overrides are there because the Movie Database app's application.yaml configures H2 in memory, as jdbc:h2:mem:moviedb with ddl-auto: create-drop. With that setup, the database is rebuilt on every start and deleted on shutdown. That suits a tutorial app, but a personal tool would lose everything you add to your watchlist whenever Claude Desktop stops the subprocess. When you point the datasource at a file and switch ddl-auto to update, the data survives restarts. The seeding logic already handles this: DataInitializer seeds only when the movie table is empty, so the second start finds the catalog already in place.

The env block also leaves out credentials. The security section above already replaced the OAuth flow with the fixed local identity. That is normal for stdio servers, because the subprocess runs with the user's operating system identity. If your server calls another backend that needs a secret, such as an external API key or a remote database password, put that secret in env.

Save the file and restart Claude Desktop, because it reads the configuration only at startup.

Verifying​

Open Claude Desktop. In a new chat, the tools panel (usually a small icon next to the input box) should list moviedb with its tools, including recommendMoviesForMood, summarizeMovieReviews and addToWatchlist. If the panel lists zero tools or shows a connection error, check the four common causes:

  • The script is not executable.
  • The JAR path is wrong, or you have not built the jar with ./mvnw clean package.
  • The Spring app fails to start.
  • The JSON configuration has a syntax error.

For a Spring startup failure, read the server's stderr, which Claude Desktop saves in one log file per server. On macOS that file is ~/Library/Logs/Claude/mcp-server-moviedb.log, and the general MCP connection log, mcp.log, sits next to it. On Windows the logs are under %APPDATA%\Claude\logs\.

Run tail -f on the moviedb log while Claude Desktop starts, and you see the Spring Boot output as it happens, including any startup failure. Two errors point back to the security section above:

  • A startup failure about a missing HttpSecurity bean means the web security configuration classes lack their @Profile("!stdio") gates.
  • Tool calls that all return An Authentication object was not found in the SecurityContext mean the stdio identity bean is missing.

Once the tools appear, ask Claude something the tools can answer, such as Recommend me a comedy movie. Claude picks recommendMoviesForMood with mood: COMEDY, calls it, gets the results and writes a recommendation. The conversation works without the agent app from Class 18, because Claude Desktop is the agent.

Claude Code CLI​

The same pattern works for Claude Code, Anthropic's command-line coding tool. Here you register the server with a command. Everything after the -- separator is the command that Claude Code runs to start the server:

claude mcp add moviedb -- /Users/yourname/projects/moviedb/bin/moviedb-mcp-stdio.sh

By default this command registers the server at local scope. The server is private to you, active only in the current project, and stored under that project's entry in ~/.claude.json. Pass --scope user to make it available in all your projects, or --scope project to share it with teammates through a .mcp.json file in the project root. Run claude mcp list to check that the server shows as connected. Then start a session with claude and ask a question such as Recommend me a movie for tonight. Claude Code discovers the moviedb tools the same way Claude Desktop does, and asks for permission before the first call.

Cursor, Continue, Cline and other coding tools that support MCP work the same way, each with its own configuration file. Once the launcher script and the stdio profile exist, connecting the server to a new client takes a few minutes of configuration.

The End of the Tutorial​

You started this tutorial with a Spring GraphQL service and a blog post that argued schemas are already MCP servers. Nineteen lessons later, you have built that server with three different stacks: Apollo, the Spring AI starter and DIY. It has:

  • descriptions taken from the schema
  • structured outputs
  • tools that report progress
  • two-way features
  • four levels of testing
  • OAuth 2.1 authorization
  • security defenses for agents
  • production observability

You can use the result in three ways: MCP Inspector for development, a Spring AI agent app for automated use, and Claude Desktop for personal use.

The schema is the contract. Every layer above it, from the MCP transport, tool definitions and descriptions to the agent prompt and the user's chat client, is built around that contract. Frontends, mobile apps, partner integrations and now AI agents all read the schemas you write. The same care that makes a schema good for one of those clients makes it good for all of them.

For your own project, pick one track. Most readers should pick Track B, the Spring AI starter, and apply the lessons that every track shares (Classes 15 to 17). The other tracks are still useful for comparison. Whichever track you pick, spend time on the schema descriptions, because they have the largest effect on how well agents use your service.

Thank you for reading. If you ship something interesting on top of this, let us know.

Summary​

This lesson switched the moviedb-mcp server from streamable HTTP to stdio. That took four pieces:

  • a Spring profile that turns off the web container and the banner
  • a Logback configuration that sends every log line to stderr, so stdout carries only protocol messages
  • a launcher script that execs the JVM
  • a claude_desktop_config.json entry that tells Claude Desktop how to start the server

The same launcher works with Claude Code through claude mcp add, and with other MCP clients through their own configuration files. We could swap the transport because everything above it, the tools, the descriptions and the schema, stayed the same.

Further Reading​

What's Next​

If you want one more lesson, Class 20 is an optional final lesson on Anthropic's "Code Execution with MCP" pattern. That pattern is the most likely challenge to the tool-enumeration model that the previous nineteen lessons use. The lesson explains how to keep your architecture ready to change if the field changes. It does not add code, and it looks at where MCP may go next.