Skip to main content

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 mcpServers JSON
  • 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
Companion code

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 describesWho edits itWhere it livesHow the model reads it
Product policy: the returns window, the shipping termsthe shoporder-serviceMCP resources, from Class 4
Support procedures: escalation, carriers, refundsthe support teammarkdown 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 serverStreamable HTTP: order-service
Who starts the processthe client, when the agent startsthe operator, separately
Lifetimeone child process per connection, starting and stopping with the agentindependent of the agent
Addresspipes only, no network addressa URL, http://localhost:8080
Who can connectonly the client that started itany number of clients
Memory and CPUout of the same budget as the agent'sits own, on its own machine if needed
Configured withcommand and argsurl

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:

  • -y is short for --yes. npx runs 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 at Need to install the following packages: ... Ok to proceed?. -y gives 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 logs npm warn exec The following package was not found and will be installed to standard error.
  • @modelcontextprotocol/server-filesystem is the package to run.
  • ./support-kb is 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.

Pinning the version

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

Shown here, not used later

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:

support-agent/src/main/resources/mcp-servers.json
{
"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 with Failed to read stdio connection resource, caused by MismatchedInputException: 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-kb does 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:

support-agent/src/main/java/com/themcpguy/supportdesk/agent/mcp/McpResources.java
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():

support-agent/src/main/java/com/themcpguy/supportdesk/agent/service/RefundEmailService.java
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.

A refused first attempt, if the model has to guess

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 this

openWorldHint 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:

ToolreadOnlyHintdestructiveHintidempotentHint
read_text_file, list_directory, search_files and the seven other readerstruenot setnot set
create_directoryfalsefalsetrue
write_filefalsetruetrue
edit_filefalsetruefalse
move_filefalsetruefalse

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.

Two of the fourteen tools, as tools/list returns them
{"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:

You run this
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 env map and servers-configuration.
  • MCP Client Boot Starter: Windows stdio configuration: why Windows needs the cmd.exe /c wrapper, from the framework that starts the process.
  • MCP Client: stdio: the layer under command and args, where ServerParameters and StdioClientTransport live, with the client's timeout settings.
  • Connect to local MCP servers: the mcpServers file 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/call and 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-server 0.2.0, registers the fourteen tools in the order printed here, sets openWorldHint: false on all of them, and prints Warning: Cannot access directory <path>, skipping before Error: None of the specified directories are accessible.
  • Filesystem MCP Server README: the per-tool readOnlyHint, destructiveHint and idempotentHint values in the table, and that a client's roots replace the directories given in args.
  • 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, env and servers-configuration under spring.ai.mcp.client.stdio, the Claude Desktop file format, and the cmd.exe /c wrapper on Windows.
  • Running your Application with Maven: spring-boot:run uses the module's basedir unless workingDirectory is configured.
  • npx: npx installs a missing package and prints a prompt, which --yes suppresses.
  • 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.