Class 9: A Server We Did Not Write
Duration: ~45 minutes | Level: Intermediate | Prerequisites: Class 8: Consuming Resources and Prompts, and Node.js 20 or later on the PATH.
What We'll Cover
- The stdio transport, where the client starts the server as a child process and talks to it over standard input and output
- Adding the npm filesystem server as a second connection, in YAML or in Claude Desktop's
mcpServersJSON - Why the orders client does not come first in the list any more, and how to look it up by server name
- One question that needs both servers, and one the filesystem server refuses to answer
This class carries on from Class 8. If you followed along, keep working in the project you
already have. If you skipped it, clone the class_8
branch to start from the same place:
git clone --branch class_8 https://github.com/the-mcp-guy/spring-ai-mcp-course.git
Using an Off-the-Shelf Server
Everything so far has been our own code on both ends. The larger benefit of MCP appears when an application connects to a server that already exists: the integration work has been done and published, and we do not have to maintain it.
The course project already contains a support-kb/ directory of notes the support team keeps as markdown files.
support-kb/
├── escalation.md when to escalate, and to whom
├── carriers.md per-carrier delays, claims windows, contact numbers
└── refunds-process.md the internal steps for issuing a refund
This is not order data, so it does not belong in order-service, and it changes whenever the support team edits a file. Building and maintaining a REST API for a folder of markdown files would cost more than the data justifies, and a server for this job already exists.
In Class 4 we put the documents that describe product policy (the returns window, the shipping terms) into order-service as resources, because that policy is part of what the shop sells. The support team's own procedures are a different kind of document, and they belong somewhere else:
| What it describes | Who edits it | Where it lives | How the model reads it |
|---|---|---|---|
| Product policy: the returns window, the shipping terms | the shop | order-service | MCP resources, from Class 4 |
| Support procedures: escalation, carriers, refunds | the support team | markdown files in support-kb/ | the filesystem server's read tools |
The server for the second row is @modelcontextprotocol/server-filesystem, published on npm, with its source in modelcontextprotocol/servers, the repository of reference servers the MCP project maintains. You start it with a list of directories it is allowed to use, and it exposes tools that list, read, search and write files inside them, refusing any path outside. It does not know anything about support desks or orders: it serves whichever directory it is pointed at, which is why it fits our case without having been written for it. The repository publishes these servers to demonstrate the protocol, and asks you to judge your own security requirements before running one in production.
The Server Runs as a Child Process
The filesystem server speaks stdio, and stdio is the only transport it offers. Its command line takes only a list of directories, so it cannot be told to listen on a port, and it does not have a URL for us to configure. The client starts the server as a child process and talks to it over that process's standard input and standard output, the two streams a command-line program normally reads from and prints to. Standard error, the third stream, stays free for the server's own log lines, because the specification allows only MCP messages on standard output.
Streamable HTTP, which order-service uses, differs on each of the points that follow:
| stdio: the knowledge base server | Streamable HTTP: order-service | |
|---|---|---|
| Who starts the process | the client, when the agent starts | the operator, separately |
| Lifetime | one child process per connection, starting and stopping with the agent | independent of the agent |
| Address | pipes only, no network address | a URL, http://localhost:8080 |
| Who can connect | only the client that started it | any number of clients |
| Memory and CPU | out of the same budget as the agent's | its own, on its own machine if needed |
| Configured with | command and args | url |
A server's author chooses which transport it offers, and as the client we configure whichever that is.
Configure the Connection
Add a stdio block alongside the existing streamable-http one in support-agent/src/main/resources/application.yaml:
spring:
ai:
mcp:
client:
name: support-agent
version: 1.0.0
request-timeout: 30s
streamable-http:
connections:
orders:
url: http://localhost:8080
stdio:
connections:
knowledge-base:
command: npx
args:
- -y
- "@modelcontextprotocol/server-filesystem"
- ./support-kb
Both transport blocks can be present in the same file: the agent now holds two connections, and List<McpSyncClient> from Class 6 has two entries.
command and args together are the command line npx -y @modelcontextprotocol/server-filesystem ./support-kb, with the command in one property and its three arguments in the other:
-yis short for--yes.npxruns a package without installing it permanently. The first time it is asked for a package it does not already have, it wants confirmation before fetching it: run the line yourself in a terminal and it stops atNeed to install the following packages: ... Ok to proceed?.-ygives that confirmation in advance. Started by the agent, the server's standard input is a pipe instead of a terminal, so npm cannot ask. It installs the package and logsnpm warn exec The following package was not found and will be installedto standard error.@modelcontextprotocol/server-filesystemis the package to run../support-kbis the directory the server is allowed to use. More directories go in as further entries in the list.
args is a proper YAML list, one of the reasons this course uses YAML. In a properties file the same setting is either one comma-separated string, split at every comma, or one args[0]=, args[1]= line per element.
@modelcontextprotocol/server-filesystem is quoted because a YAML value starting with @ is reserved.
There is also an env map for connections that need environment variables, which is how a server taking an API key is configured.
npx -y @modelcontextprotocol/server-filesystem runs whichever version npm resolves at startup, and a deployment can pin it:
args:
- -y
- "@modelcontextprotocol/[email protected]"
- ./support-kb
OWASP counts vulnerable and outdated third-party components among the supply chain risks, and a pinned version is the one you tested. The MCP guide to local servers and the Spring AI documentation both show the unpinned form. The course leaves it unpinned so the lesson keeps working as the package moves.
On Windows: npx needs a cmd.exe wrapper (click to expand)
On Windows npx is a batch file, and Java cannot start a batch file as a process directly; the call has to go through cmd.exe:
command: cmd.exe
args:
- /c
- npx
- -y
- "@modelcontextprotocol/server-filesystem"
- ./support-kb
The same wrapper applies to the mcp-servers.json form below.
Make the relative path resolve
./support-kb is relative to the working directory of the process that starts the server, and mvn -pl support-agent spring-boot:run uses the module directory. So the filesystem server would be handed support-agent/support-kb, which does not exist.
Tell the Maven plugin to run from the repository root instead. Add this to the parent pom.xml:
<build>
<pluginManagement>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<workingDirectory>${maven.multiModuleProjectDirectory}</workingDirectory>
</configuration>
</plugin>
</plugins>
</pluginManagement>
</build>
Without it, the server is handed a path that does not exist. The Java SDK writes everything the child process prints to standard error into the agent's log:
STDERR Message received: Warning: Cannot access directory /Users/you/mcp-spring-ai-course/support-agent/support-kb, skipping
STDERR Message received: Error: None of the specified directories are accessible
The first line names the absolute path that was tried. The child then exits, and the client waits for a handshake that does not arrive:
Client failed to initialize by explicit API call
Caused by: java.util.concurrent.TimeoutException:
Did not observe any item or terminal signal within 20000ms
The 20000ms in that message is not the request-timeout: 30s we configured: the
initialization handshake has its own timeout, and 20 seconds is its default.
An absolute path in args works too, and is what a deployment would use. The relative
path keeps the repository portable.
Declaring connections in Claude Desktop's JSON format
The configuration stays as it is: the course keeps the stdio.connections block from
above, and the companion branches are configured that way. The JSON form is here because most
published MCP configuration is written in it.
servers-configuration lists stdio servers in a JSON file instead of the connections map, and the format it expects is the mcpServers block Claude Desktop writes in its own configuration file:
{
"mcpServers": {
"knowledge-base": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "./support-kb"]
}
}
}
spring:
ai:
mcp:
client:
stdio:
servers-configuration: classpath:mcp-servers.json
Nothing here configures Claude Desktop. The borrowing goes the other way: our agent reads a file written in the shape that client uses, so a block you already have for the desktop client can be copied across as it stands. In Class 17 we do the opposite, and register order-service in Claude Desktop's configuration file so the desktop client starts it.
servers-configuration is a Spring Resource, the framework's own way of naming a file to read, and a different thing from the MCP resources of Class 4. Besides classpath: it accepts file:, including the desktop client's own file, so both programs read one list. Two things to know before sharing a file that way:
- Spring AI takes the value of the first top-level key and never checks its name, so everything under that key has to be a server definition. Claude Desktop's file may hold settings of its own, and one of those is enough to stop the agent starting. With
"globalShortcut": "Alt+Space"in the file, startup fails withFailed to read stdio connection resource, caused byMismatchedInputException: Cannot deserialize value of type LinkedHashMap<String, Parameters> from String value. - The desktop client and the agent are started from different working directories, so a relative path such as
./support-kbdoes not point at the same folder in both. A shared file wants absolute paths.
The two sources can be used together: Spring AI reads the JSON file first and then adds the connections entries, so a name appearing in both takes its command from application.yaml. Keeping to one source is easier to follow, but mixing them works.
What the Agent Can Answer Now
Restart the agent. Class 6's inspector reports both connections:
Connected to secure-filesystem-server 0.2.0
tool read_file
tool read_text_file
tool read_media_file
tool read_multiple_files
tool write_file
tool edit_file
tool create_directory
tool list_directory
tool list_directory_with_sizes
tool directory_tree
tool move_file
tool search_files
tool get_file_info
tool list_allowed_directories
Connected to order-service 1.0.0
tool get_customer_orders
tool get_order
tool get_orders_by_status
tool update_order_status
resource policy://shipping
resource policy://returns
template order://{orderId}
prompt draft_refund_email
All fourteen of the filesystem server's tools are now available to the agent, and adding them took only the configuration block above; no Java code changed.
The First Client Is No Longer the Orders Client
The inspector output shows why one thing broke: the filesystem client now comes first in the list, and in Class 8 we picked the orders client with clients.getFirst().
To see it break, keep order-service and the frontend running and ask the question from Class 8
again at http://localhost:5173:
Where is order ORD-10001, and can the customer still return it?
The answer that arrived in Class 8 does not arrive now. The chat panel prints the line it shows
whenever /api/chat answers with anything other than 200:
support-agent returned 500. support-agent is built in Class 7.
The second sentence is the panel's standing advice for a backend that is not running. Here the agent
is running, and the reason for the 500 is in its log:
java.lang.IllegalStateException: Server does not provide the resources capability
chatWithPolicy asked the filesystem server for policy://returns. The right client is the one whose server reported itself as order-service in the handshake. In McpResources, keep the whole list and add a lookup by that name:
private final List<McpSyncClient> clients;
McpResources(List<McpSyncClient> clients) {
this.clients = clients;
}
public McpSyncClient orders() {
return clients.stream()
.filter(client -> "order-service".equals(client.getServerInfo().name()))
.findFirst()
.orElseThrow(() -> new IllegalStateException(
"Not connected to order-service. Is it running on port 8080?"));
}
read then calls orders() where it used the field: orders().readResource(...). That line draws
the same 'McpSyncClient' used without 'try'-with-resources statement warning as Class 6, for the same
reason: the client is a bean whose lifetime belongs to Spring, and closing it here would take the
connection away from every later call.
RefundEmailService picked its client the same way. Go through McpResources instead: inject it in place of the client list, and fetch the prompt with resources.orders():
private final McpResources resources;
private final ChatClient chatClient;
RefundEmailService(McpResources resources, ChatClient.Builder builder) {
this.resources = resources;
this.chatClient = builder.build();
}
var result = resources.orders().getPrompt(GetPromptRequest.builder("draft_refund_email")
.arguments(Map.of("orderId", orderId, "reason", reason))
.build());
Restart the agent, and the Class 8 question works again in the frontend app.
Now a question that needs both servers:
ORD-10001 was shipped with DHL and hasn't arrived. What should I do?
The answer names the carrier, the tracking number and the shipping date, then advises tracking the
parcel and contacting DHL. The log shows why it stopped there: get_order was the only tool called,
and nothing was read from support-kb.
The tools are there, and nothing tells the model to use them
The fourteen tools arrived with descriptions written by their author, and they describe the mechanics
of reading files: read_text_file reads "the complete contents of a file from the file system".
They do not say that the directory this server was started with holds the support team's own
procedures, because the server does not know that it does.
So the knowledge that carriers.md is worth opening for a delayed DHL parcel does not exist in the
system yet. It is ours, and it belongs in the system prompt. Add a guideline to BASE_SYSTEM
in SupportAgentService, next to the ones from Class 7:
- The support team's own notes are files you can read with the filesystem
tools. Use them for carrier delays and claims windows, for escalation, and
for the refund procedure. Call list_allowed_directories to find the
knowledge base, list it to see what is there, then read the file you need.
Restart the agent and ask the same question again. Both servers now take part, in this order:
The step to look at is the second request to the model: with the carrier in hand, it asks where the knowledge base is before reading anything, because the guideline told it to start there.
list_allowed_directories is in the guideline because the model does not know where the knowledge base
is on disk. Left to guess, it asks for something plausible and is refused:
Executing tool call: list_directory
ERROR o.s.ai.mcp.SyncMcpToolCallback : Error calling tool: [TextContent[...
text=Access denied - path outside allowed directories:
/ not in /Users/you/mcp-spring-ai-course/support-kb, meta=null]]
The request was for /, and the refusal names the directory the server will accept, so the model
tries again inside it and carries on to the file. That recovery is the arrangement from Class 7,
where a tool error goes back to the model instead of to our code. It works because the message meets
the standard we set in Class 3 for our own errors: enough detail to fix the call.
The two servers do not communicate with each other: each one only sees the requests the client sends it, and the model is what combines their results.
When do I escalate a delayed order to a manager?
That question does not need an order at all. The model reads escalation.md and answers from it.
openWorldHint was about thisopenWorldHint says whether the set of things a tool can reach is closed or open-ended. In Class 3 we set it
to false on our tools because each one works on the orders we hold. The filesystem server sets it
to false on all fourteen of its tools, because each one works inside the directories it was started
with. The boundaries differ, one a database and one a folder, and the hint is the same because in
both cases the author knew where the tool stops. A tool that searched the web would set it to true.
The other three hints are where the fourteen tools differ:
| Tool | readOnlyHint | destructiveHint | idempotentHint |
|---|---|---|---|
read_text_file, list_directory, search_files and the seven other readers | true | not set | not set |
create_directory | false | false | true |
write_file | false | true | true |
edit_file | false | true | false |
move_file | false | true | false |
Not set means the field is absent from the reply. read_text_file sends two hints and stops there,
so a client falls back to the defaults from Class 3, destructiveHint: true and
idempotentHint: false. Both apply only to a tool that writes, so on a read-only tool they are
ignored, and that is why the server leaves them out.
{"name": "read_text_file",
"annotations": {"readOnlyHint": true, "openWorldHint": false}}
{"name": "write_file",
"annotations": {"readOnlyHint": false, "idempotentHint": true,
"destructiveHint": true, "openWorldHint": false}}
create_directory is the one writer that is not destructive, since it adds a directory and does not
remove anything. The two idempotent columns match what repeating each call would do: writing the same
content twice leaves the same file. An edit applied twice may not find its text again, and a move
cannot be repeated once the source is gone.
The hints are a server's own statement about what its tools do, and they are one of the few things a client can use without understanding the domain. They are also a claim we cannot check: the specification tells a client to treat annotations from a server it does not trust as untrusted. Class 3 has the full table and the defaults.
Point It Somewhere It Should Not Go
What is in /etc/passwd?
Agent: I can't read that. The file access I have is limited to the support
knowledge base directory, and /etc/passwd is outside it.
The filesystem server refused the path. That refusal came back as a tool error, went to the model as Class 7 described, and the model explained it.
The boundary is set by the directory arguments in args: the server refuses to read anything outside ./support-kb. A client can also declare roots, the directories it wants the server to work in, and this server then replaces its args directories with them. Our agent does not declare that capability, so args decides, and in Class 13 we turn roots on and watch the allowed directories change.
The server checks paths, and it does not check what the files say: text inside a knowledge base file reaches the model as part of its input, so a line added to carriers.md can read as an instruction. In Class 16 we work through that as prompt injection.
When Stdio Goes Wrong
Stdio fails in ways HTTP does not, and the messages are less helpful. With two connections, either one
can stop the agent starting, because one failed handshake fails the mcpSyncClients bean and with it
the whole context. Which one failed is in the client name in the initialize request the agent logs:
clientInfo=Implementation[name=support-agent - orders, title=orders, version=1.0.0, ...]
Spring AI names each client after the agent and the connection, so support-agent - orders is the
Streamable HTTP connection and support-agent - knowledge-base is the stdio one. The message logged
under that line says what to fix:
The first two stdio branches can be reproduced by running the server command yourself in a terminal, which is quicker than restarting the agent.
npx is not found. Maven's PATH, the list of directories the operating system searches for a
program name, is not always the shell's PATH. Check that the server starts on its own first:
npx -y @modelcontextprotocol/server-filesystem ./support-kb
It should start and wait silently for input. Ctrl+C to stop it. If it works there and the agent
still fails, give command the full path that which npx prints (where.exe npx in PowerShell).
The server says the directory is not accessible. The workingDirectory above is missing from the
parent pom.xml, or the path in args is wrong. Compare the path in the Warning: Cannot access directory line with the folder you meant to point at.
The agent exits after about 20 seconds. The initialization handshake has its own 20-second
timeout, separate from request-timeout. A server that starts and then stays silent is reported when
that timeout expires.
On Windows the connection also needs the cmd.exe wrapper from the collapsible in
Configure the Connection, because npx is a batch file.
To watch the traffic, set logging.level.io.modelcontextprotocol to DEBUG, the logger we used in Class 6 to show the handshake.
What We Built
The agent talks to two servers over two transports: order-service over Streamable HTTP, and the filesystem server from npm as a child process over stdio.
The connection itself was only the stdio block in application.yaml, and the one Java change was our own: the orders client is now looked up by server name instead of taken as the first in the list.
In Class 10 we connect a third server whose tool names collide with the existing ones.
Next: Class 10: Several Servers at Once. Two servers offering the same tool names, and McpToolNamePrefixGenerator and McpToolFilter to control the result.
Further Reading
- Transports: stdio: the normative rules this class depends on, including that the client launches the server as a subprocess and that only MCP messages may go to standard output.
- Filesystem MCP Server: the server's own README, with every tool, its arguments, the annotation table, and the two ways its allowed directories can be set.
- MCP Client Boot Starter: stdio transport properties: every stdio property Spring AI reads, including the
envmap andservers-configuration. - MCP Client Boot Starter: Windows stdio configuration: why Windows needs the
cmd.exe /cwrapper, from the framework that starts the process. - MCP Client: stdio: the layer under
commandandargs, whereServerParametersandStdioClientTransportlive, with the client's timeout settings. - Connect to local MCP servers: the
mcpServersfile this class borrows, written from the Claude Desktop side, with the note that a stdio server runs with your own account's file permissions. - Tools:
tools/list,tools/calland tool annotations in their normative setting, with the two kinds of tool error Class 7 relies on.
Sources
- Filesystem MCP Server: index.ts: the server reports itself as
secure-filesystem-server0.2.0, registers the fourteen tools in the order printed here, setsopenWorldHint: falseon all of them, and printsWarning: Cannot access directory <path>, skippingbeforeError: None of the specified directories are accessible. - Filesystem MCP Server README: the per-tool
readOnlyHint,destructiveHintandidempotentHintvalues in the table, and that a client's roots replace the directories given inargs. - Model Context Protocol servers: these servers are published as reference implementations for learning, with the request that you judge your own security requirements before production use.
- Transports: the child process, the two pipes, standard error left free for logging, and the Streamable HTTP server as an independent process serving many clients.
- Schema reference:
openWorldHint: what the hint means, and the defaults a client applies to the hints a server leaves out. - Tools: clients must consider tool annotations untrusted unless they come from trusted servers.
- MCP Client Boot Starter:
command,args,envandservers-configurationunderspring.ai.mcp.client.stdio, the Claude Desktop file format, and thecmd.exe /cwrapper on Windows. - Running your Application with Maven:
spring-boot:runuses the module'sbasedirunlessworkingDirectoryis configured. - npx: npx installs a missing package and prints a prompt, which
--yessuppresses. - LLM01:2025 Prompt Injection: indirect prompt injection through files a model reads, which is what a knowledge base directory becomes.
- LLM03:2025 Supply Chain: third-party package risk, behind the note on running an unpinned npm package at startup.