Here is the single most important thing to understand about resources: a resource is not a tool that returns content. It's a different primitive for an entirely different purpose, and blurring those lines is one of the most common mistakes people make when they're new to MCP.
Think about the difference between a phone call and a reference manual open on your desk. The phone call gets something dynamic and current. The manual is just there to look at, glance at, read, figure out what it says, and then keep going. Resources are that manual.
So why not make everything the fun dynamic phone call? Well, you could write a get_workbook_schema tool that returns the same JSON every single time — but that's a whole tool call. The model has to decide to make it, and it costs a round trip. It costs tokens in both directions for content that never changes.
It's a terrible trade. A resource sits there, readable, and the model just has it. No decision, no round trip, no cost. That's what resources do.
Let's add a new file for resources. Inside our workbook tools folder, let's make WorkbookResources.java.
To it we're going to add a couple of imports. We include McpServerFeatures.SyncResourceSpecification — if you recall, we had SyncToolSpecification for tools, so SyncResourceSpecification for resources. There's also ReadResourceResult, Resource, and TextResourceContents. These will support the resources we're adding in this section.
In the Java SDK, we add a new resource like this. A SyncResourceSpecification called dummyResource. The resource is Resource.builder() — that same builder pattern — with the URI workbook-tools://dummy, named "dummy resource."
Now, this URI isn't the same thing as a URL, but it's sort of an internal way for the LLM to query the resources we have available.
The title of this resource is "dummy resource." The description is "placeholder resource for testing resource wiring." And the MIME type is text/plain, just letting you know how it gets read.
We return a new SyncResourceSpecification where we build the text contents of request.uri(), and then just give it a description — text: "this is a dummy resource." That's it.
We can wire this back into the server with a pretty small addition. If you go back into the MCP server, we have capabilities. We have this builder().tools(...).build(). All we have to do is in this chain add .resources(...). We're setting both parameters to false for now just to keep it simple, and build.
Those two booleans describe subscriptions and listChanged — whether clients can subscribe to updates on individual resources, and whether the server announces changes to the resource list. Again, keeping it simple, just keep them false for now.
Then, just like before, where we have server.addTool, let's make a new section: server.addResource. It's going to reference WorkbookResources.dummyResource. Simple as that.
All right, let's make sure it's working. mvn clean compile.
Let's open up the Inspector. Like always, I'll copy over that token, go into the Inspector, Maven exec:java, paste it in, connect. And we have the tools from before, but now there's a Resources tab. Here, we can click "List Resources." There's our dummy resource with the URI we expected and the text just like this.
Not every chunk of content deserves to be a resource, and not every resource you write will be a good one. A few things separate the resources the model reaches for from the ones it ignores.
Clarity is important. The model sees a name and a description before it ever reads the content, just like with tools. It has to be able to tell from those two strings alone whether the resource is what it needs. "Workbook generator schema," with a description that says exactly what it contains, beats a resource named "config" with a description that says "configuration data." Be specific. You're not naming a local variable — you're naming something the model might have to choose between among dozens of options.
Scope has to be managed very carefully. Many focused resources beats one giant resource that tries to be everything. I've seen people take entire project READMEs, dump them into a single resource, and call it done. Don't do that. If the model needs the schema, give it the schema. If it needs usage examples, give it usage examples. They're separate ideas and ought to be separate resources. A monolithic resource forces the model to read through content it doesn't need to find the part that it does — and that is tokens spent for nothing on every single read.
Resource freshness can cause bugs. A static resource is only honest if the thing it describes doesn't change. The moment your workbook schema gains a new required field and your hardcoded JSON resource does not, you now have a resource that is actively lying to the model. Nothing throws an exception when a resource is just wrong. The model reads it, trusts it, and reasons from bad data.
So let's add some effective resources. I think a good one to start with would be that column spec we defined earlier. It can help the LLM understand how to form a correct request.
In resources, let's add this new function. So you thought you were done with schemas? Not quite yet. However, this schema is the same as the schema we defined in the tools section — the properties, header, types, all of those. The same information is appearing here.
Resources are defined with the SyncResourceSpecification. This is the columnSpecSchemaResource. It's a resource built with the URI workbook-tools://schema/column-spec. The name of it is "column spec schema." It has a title and a description: "the JSON schema for a single column specification accepted by generate_workbook," with the MIME type — that's just how it's read in.
Then we return a new SyncResourceSpecification of the resource, which is really just returning the request URI of that aforementioned column spec schema.
Since this is so tightly coupled to what we have for the generate tool, it makes sense to update the generate_workbook tool as well. Let's add information to the description about this.
Same thing as before, but we're adding "see resource workbook-tools://schema/column-spec for more information." This kind of back-and-forth is really helpful for LLMs. You can declare something, but also point to a different tool, so that if it was running generate_workbook it would know enough from the description to check this as well — or the other way around. If it was looking at this resource specification, it knows to use it for the generate_workbook call.
But that's not all. Resources can be dynamic too. Let's add one that reads a whole file as context into the LLM. Let's have it read something like the generated workbooks we created earlier.
To do that we'll need a couple more imports. Up at the top, we add SyncResourceTemplateSpecification. You'll remember SyncResourceSpecification is for resources; resource template is for dynamic resources. We include McpError from last time and McpSchema from last time, with the resource template here.
Additionally, we import some more Java functionality: array lists, IO exceptions, input streams, file exceptions, standard character sets, files, and paths. These are going to help us read the workbooks. And to be even more specific, we're going to use cells, rows, sheets, and workbook objects.
To our WorkbookResources we add a path for resources to reference. This path is the output directory, and it's the same property as the workbook output directory — that generated-workbooks folder we created earlier.
Then, in order to provide it as a resource, we add a new SyncResourceTemplateSpecification, workbookFileResource. This template follows the template builder using the URI workbook-tools://workbooks/{fileName} — the braces indicate that fileName is an input variable that's going to be substituted into the URI.
The description is "the full contents of a generated workbook as CSV, for verifying against the rendered viewer." We'll get to that later on.
This returns a new specification. substring, lastIndexOf — this is taking the file name we put in and breaking it up. So if we put in workbooks/lorem.xlsx, it would get that lorem file name, resolve it from the output directory to get the path, and then take that path as a new file to read in.
It reads all the information on the sheet into rows and into cells, and then saves all of that to a CSV string. It'll then take that CSV string, build it, and return it as a resource. Pretty straightforward.
There's also the csvEscape function here. This is a helper that's going to handle all those escapes all over a CSV. Never done that before? Give it a try — it's pretty interesting to look at.
The last resource I want to add is going to be an actual document that we refer back to. No code, nothing like that. Just an explanation of the columns that we support.
The standard practice for this is to go into the main folder, create a new folder for resources, and then a new folder called docs. Inside that docs folder, a new file called column-types.md — a Markdown file.
Markdown files are just English-language document types. There's no code inside any of this. It's just information in English that the LLM can read and reason from. All this is explaining text types, integer types, decimal types — all things it can reason from to create correct information for the workbooks. It also, at the bottom, references a different resource. Chaining resources together like this is a very effective way to get the model to follow a workflow or to link things together.
In order to add it to what we have, I'll paste it in. So there's this new COLUMN_TYPES_GUIDE string, which just references the Markdown file we created. It loads the classpath resource — that's what this function's doing, loading in the resource as a stream.
And then we have our SyncResourceSpecification with the URI workbook-tools://docs/column-types, and the description explains how all of this works. We return a new SyncResourceSpecification, which builds the request URI using the column types guide.
With that in place, we can go back to the MCP server and finally add in all of the resources we just made. Voila. Just like that, we've added the resources for the column spec schema, the resource template for the workbook file, and the resource for the column types guide Markdown file.
Let's run clean compile to make sure it's still good, and it builds.
With all those resources defined, we should be able to open the Inspector back up and investigate the changes we made. I've copied over the token, let's open up the Inspector, place that token in, and connect.
First things first, the resources. Our column types guide appears here with the same text that was inside the Markdown file. And we have the column spec schema that has that same string we listed earlier.
The interesting one is the resource templates, where we're able to enter a file name and see if it loads. We already have lorem.xlsx here. So let's enter lorem.xlsx and load the resource. And hey, look at that. It loaded in all the information from that Excel file.
Now that we have resources and tools set up, we can finally do a demo using Claude Code. This is just the one that I'm using — you can use whatever works for you.
You can view your MCP servers and see I currently have workbook tools connected. I can reconnect it to make sure it's fresh, and I can view the tools I have available.
The key here is that we want to use only the workbook MCP server and don't read any code: "Check the resource for column types to generate me a comprehensive workbook to use as an example for a test."
This is the kind of thing that we'd find in a practical situation using an MCP. It's going to start by reading the MCP resource, just like we asked it to do. It checked what the column spec looked like. It then went and used generate_workbook to create the workbook, and then list_workbooks to verify what was inside the folder.
Because of that, it knows that it generated the comprehensive example workbook with 200 rows using all five column types. It was smart enough to do that because it double-checked the column spec, plus information about the blank probability.
There we have it. Simple as that. And how does it look? Ooh, that is nice. Gorgeous.
So that's the real magic here: you can give it a fairly vague request, something like "generate a comprehensive workbook," and because we gave it all these tools, it was able to generate all of this data for us to use for a test case. Not bad, huh?
That's the second primitive. The model could act, and now it can read.
In the next section, we step back a little bit and talk about how to actually talk to it. Effective prompting and MCP prompts stop your agent from thrashing.