We have a server. It starts, it registers, it does nothing. Let's fix that.
Tools are the most important primitive in MCP. They're callable functions — things the model can invoke to take action or get results. Not static context it reads, not templates it fills in. Executable logic. The model decides to call a tool, your server calls the method, and the result comes back into the conversation.
Think about what that means for a second. It is a function call. That function can do anything a function can do. It can query a database, write a file, trigger a build, hit an external API. The model reaches out and touches the real world, and it does so through your code.
So how does the model know which tools to call? This is the part people underestimate. It reads the tool's name. It reads the tool's description. It reasons about what the user asked for and it picks what fits.
That is the entire selection mechanism. It doesn't run code to figure it out. It just reads your words — which means that the quality of your descriptions is not a documentation problem. It is a functionality problem. A poorly described tool is a tool the model will not use correctly, or will not use at all.
The lifecycle goes like this. The model sends a request to your server naming the tool and passing arguments. Your server receives it, runs the handler, and returns a result. That result goes back into the context window and the model uses it to form a response. Good input to the model means good output from the model. Tools are how you control that input.
Let's make a new file. In the same folder as everything else, let's make WorkbookTools.java. It's going to be a public class, and let's add a few new imports.
To start, we're going to add the same McpJsonDefaults. We're going to include SyncToolSpecification. We're going to include CallToolResult and McpSchema.Tool — this will make sense later. Additionally, we're going to import a handful of basic Java options: IO exceptions, files, paths, dates, lists, maps, streams. These are things that we'll need that'll become clearer as we add more code.
Lastly, a couple extra support objects. First, a path we'll call the output directory. This is the workbook output directory, called generated-workbooks. This is just the place we're going to put the workbooks that we generate. And lastly, the WorkbookGenerator object called generator — simply a new instance of the object we went over previously.
Here is the full shape of a tool definition. We're going to add a greeting tool. Its object type is SyncToolSpecification, and to it we return the SyncToolSpecification builder object. It's .tool, it's a new tool, and inside that we call Tool.builder. The name of this is going to be get_greeting, plus that same McpJsonDefaults and a schema variable — I'll get back to that in a moment.
There's going to be a description: "returns a greeting for a given name." This is information for both the user and for the LLM to reason about. Then we call build to close up that bit.
Additionally, there's the call handler. That's boilerplate code for the MCP server and the LLM to communicate. It's going to request an argument of name — the name that you should be greeted as. And when it returns, it's going to build text content, "hello " + name, whatever name it is, and build it.
So that's the greeting tool. Basically you enter a name and it says "hello, name."
The key thing that we're missing here is our schema, and this is where things can get a little tricky. So follow along with me.
This is the schema. It's triple quotes — that's fun, that's different. It indicates that this is going to be a string, but specifically this is useful for JSON strings. Inside brackets: type, object. The properties of the schema is a name, which is a type string. And that name is required.
Make sure that you keep your argument names specific and intentional here. We're using name to refer to the name we're using. If we had it be myVar or thisVar or inputData, whatever it is, that wouldn't be intuitive to the LLM or to the end user. The model is going to read this name and decide what to do with it. This is the tool's API surface to the model.
So there's a greeting tool. That's all it is. A schema, a tool that you build, a name, a description, and then when you call it, you return what it does. Pretty straightforward.
In order to make this actually found, we go back to the WorkbookMcpServer and make a couple of updates.
First, we import the McpSchema.ServerCapabilities information. Then we update the MCP server class: McpServer.sync with our transport provider, and we add capabilities here — ServerCapabilities.builder().tools(true). Essentially, we're telling it that we need to include tools in this MCP server.
Similarly, we turn the server into a variable. Before, it would just build it and it didn't matter. But now we want to be able to operate on this server, so we assign it to a variable. Then we can call server.addPrompt, addResource, addResourceTemplate, and addTool — IntelliSense is quite literally intuiting for us the things that we could potentially do.
In this example, we're going to add the tool, and we should be able to just call WorkbookTools.greetingTool(). There it is. Just like that, it's available.
I'm going to call mvn clean compile just to make sure this still works. It looks like it passed. Build success.
Then I'm going to call the Inspector tool again just to verify. We're going to need this session token just like last time. STDIO, Maven, exec:java, configuration. Let's put in that new proxy session token and connect it.
We see something happening in the background here. And look — there's a Tools tab now. We can click "List Tools" to see get_greeting has appeared. It tells us information about what this new tool does, and it asks us for the name that we defined earlier.
Let's leave it blank and just see what happens. If we run it: success, "hello, [blank]." Right? Because we never specified that it needed to be a certain way — nothing is valid information. Let's put in a name. "Hello, Turch." There it is. This tool is working. It's as simple as that.
Now that this example is working, we can start implementing real and interesting tools. Before designing a tool, it's worth asking: what does this tool actually need to do?
I approach tool design the same way I approach test design — with behavior-driven development. Good old BDD. If you need a refresher, Test Automation University has a few courses you can check out. But in short: given some starting state, when some action happens, then an outcome occurs.
That structure is a forcing function for clarity. And when applied to tools, it maps directly onto a tool signature.
Take our Workbook Generator. We have a Java library that produces Excel files. The Workbook Viewer web app renders those files in a browser. The goal is for an AI agent to generate workbooks on demand, load them into the viewer, and validate what shows up. That's the workflow.
Now let's break that into tools:
Write those Given/When/Then statements before you write any code. The given tells you what inputs the tool needs. The then tells you what the return value should look like. The when is the handler.
I get it if this sounds like extra work. It's not. It catches bad tool design before you've sunk time into it. If you can't write a clean Given/When/Then for a tool, you haven't fully separated out your concerns yet. It's valuable insight, and it's free to discover now — but much more expensive to discover after you've written and registered the tool.
Each new tool we add is going to follow the same builder shape we just covered. So let's build the most important one, and the most complicated one, first: generate_workbook.
Same format as before. The SyncToolSpecification, generateWorkbookTool. It returns that builder. The tool is named generate_workbook. It includes a schema — same sort of thing as before, but this one's going to be kind of complicated.
It has a description: "generates a sample Excel workbook from column specifications and writes it to an output directory."
The handler notes the arguments: a destination, a row count, and columns — all pretty straightforward. The destination resolves against the output directory, along with the row count and the columns of the Excel file we want. If there are any errors trying to build any of those, we return a CallToolResult noting that there was an error of invalid argument.
We then try to call generator.generateSample. If you're not sure what that is, you can go into it and see it's in the Workbook Generator we made. The generate function requires that we have a sheet we write out that has samples in it. These samples generally have a header, rows, and columns where they add random values and generate headers and rows.
After we're done generating the samples, it creates the directory from the output we defined earlier and generator.writeToFile writes the bytes at the destination. It's just making a file. If there's a problem, it'll catch this and note the CallToolResult — build a failure that it failed to generate the workbook, and that it is an error. And finally, if everything did go well, it'll return a CallToolResult noting that it wrote the destination file.
There are a couple of support functions we need to include as well. fileNameOf just gets the file name — specifically, it checks to make sure that it has the .xlsx extension, and if we didn't put it in there, it'll add it to make it easier for us. And columnsOf gets the information about the columns we've defined; it returns the stream that we've mapped from WorkbookTools.parseColumn to a list.
Now, the schema for this call. It's a much bigger object than the last one, but it's still an object with properties. There's a lot more this time though:
fileName, a string — a description of the destination file name.rowCount, an integer, with a minimum of one.seed, which we can set to have a reproducible output if we find a weird thing and want to do the same thing again.columns, an array where there's at least one item. The items are objects that have a header, type, words, min, max — all these options that we can include, start and end for dates, all those sorts of things describing how the schema works.parseColumn does a lot of the heavy lifting. What it's doing is taking the information out of the schema we've described — the column information — and switching over the type that it is. If it's text, an integer, a decimal, a date, whatever it is, it casts that into the correct format with whatever the minimum is and whatever the maximum is, so that we know easily what the column information is for the MCP server. To support that, we'll need stringListOf, which just gives a list of valid options.
That was a lot. However, the next two tools are a lot more simple.
The next one is list_workbooks. Another SyncToolSpecification. We build the tool list_workbooks. The description is that it lists the workbooks in the output directory. It notes if there's a failure, or if there are no workbooks generated. It attempts to stream the list in the output directory of all of the files, checks if those files are in the Excel format, creates a map of those, sorts them, lists them, and returns them. If there's some kind of issue where there are no names, it returns "no workbooks." Or if there's some kind of exception, it'll say it failed to list the workbooks.
Lastly is clear_workbooks. This deletes all the workbooks that we currently have in our folder. Get the JSON mapper defaults. The description is that it deletes all the workbooks from the output directory. It checks to see if that directory you indicated is a directory. If not, there's nothing to clear. Otherwise, it's going to try to create a list of all of the files in that directory, check all the ones that end with the Excel extension, create a list of those, and then for everything inside of that, attempt to delete each path. If that doesn't work, it'll note that it failed to clear the workbooks. But if it does work, it'll return a CallToolResult with the number of workbooks deleted.
There it is. Those are all the tools we're going to build. It's a lot. Please, I encourage you, go back and watch this slowly — you might need to do some copy and paste.
Whenever you're ready to move on, we can go into the WorkbookMcpServer and all we have to do is add these tools: generateWorkbookTool, listWorkbooksTool, and clearWorkbooksTool.
Let's call mvn clean compile to make sure this is all good. And voila, build success.
We have three-ish tools here with clear responsibilities and no overlap. The model can look at the names of the tools, read the descriptions we added, and know exactly what to reach for and when. That's the whole point. That's the whole game with tool design.
Your tool runs. It produces output. Now ask yourself: is that output actually useful to a model?
Models reason through text. Whatever you put in that CallToolResult is what the model reads and reasons from. So the question is not just "did my tool succeed?" It's "did I give the model what it needs to continue?"
One trap is returning too much. If generate_workbook returns the entire contents of the file it just produced — every cell, every row, every value — you just burned a big chunk of your context window on content the model didn't ask for and probably can't do anything with.
Keep it focused. Return a summary. Return the file path. Return a status and a row count. Let the model ask for more details if it needs them.
On the flip side, you can return too little, or worse, return the wrong thing for the wrong reason.
The SDK has a real distinction here that's worth understanding. There are two tiers of errors and they're not interchangeable.
The first is a top-level error — something the model could recover from. A missing argument, an invalid value, a spec that doesn't have any sheets defined. For these, you return a CallToolResult with isError set to true. The model gets this as a normal tool response, reads the message, and can self-correct on its next attempt. That's the whole point.
The second tier is a protocol-level error — something the tool genuinely cannot recover from. A database that is gone, a system failure. For those, you can't build a CallToolResult. You throw an McpError. That propagates as a JSON error rather than as a tool response. Use it for things that are genuinely unexpected, not for validation failures. If you're throwing McpError because a parameter was blank, you're using the wrong tier.
So prioritize practicality. Give the model something to work with when things go wrong, not just a signal that they did.
Let's add some structured results. We can start with the successful information.
Inside WorkbookTools.java, let's add a few more imports: McpError and McpSchema. Then let's create an object to contain the workbook results — a new WorkbookResult that contains the file path to the workbook as well as the row count.
Then, to support our WorkbookResult, we need an output schema. As if you haven't seen enough schemas for a lifetime, let's get one more in there. So we now have an output schema in addition to our regular input schema. This is also a type object with properties, just like all the rest. This one has filePath, a string, and rowCount, an integer. They're both required values. Hopefully these schemas are getting a little easier for you to understand.
Now that we have this new schema, we add it to the tool builder. We have the description, and after the description we include the output schema — default mapper with the output schema in it. Then at the bottom, as part of the return, we note this new structured content. The result builder adds text content plus structured content: a new WorkbookResult with the destination as a string and the row count included.
So that's the happy path. Let's add some sad.
For recoverable errors, the CallToolResult class we already have is pretty effective at explaining what happened. It's great for the invalid-argument error. So it attempts to get this information, and if there's some problem doing that, it'll let us know that the tool errored correctly. There's an invalid argument in there. It's not information the MCP server needs to guide it — it's not exactly an McpError.
For genuine infrastructure failures the model can't act on, we throw an McpError instead, which — like everything else here — is built with a builder. So to override the existing CallToolResult, if something goes wrong as part of this generation process, instead of returning a CallToolResult, we throw an McpError: a builder with the internal error code, "unexpected failure writing workbook," with that message. This should be a lot clearer to the MCP server that something has gone wrong with our process.
We can update it in the same place in list_workbooks. Instead of "failed to list workbooks," let's note this was an McpError. And the same thing down in clear — instead of "failed to clear workbooks," we note there was an McpError clearing the workbooks. This gives stronger information and stronger feedback to the model, as opposed to a more generic response.
Now finally, let's verify with the Inspector once again. Start by calling clean compile. Make sure we get build success, then operate the Inspector.
We have that session token there. Let's move over to our MCP Inspector — STDIO, Maven, exec:java, configuration. Let's clear out that old token. Let's connect.
All right. So we went from one tool to four. Look at that. get_greeting is still present, but now we have generate_workbook, list_workbooks, and clear_workbooks, all with the descriptions we gave them.
Let's check out list_workbooks. It knows exactly what it does. If you click to run it — "no workbooks were generated yet."
Let's get to the interesting one: generate_workbook. Let's have the file name be lorem. Let's give it 10 rows. No need for the seed this time. And into the columns, let's input this JSON. I've already created this schema to have a header of product, which is text; category, which is a choice of one of these options; a quantity column, which is an integer between 1 and 100; price, a decimal between 5 and 250; an order date between those two dates; and some notes — just some word options that could be in there, with the chance for it to be blank as well.
Let's run this tool and see what happens. It succeeds. And the structured content that we declared has the file path listed here, lorem.xlsx, with 10 rows. And what do you know — here inside we can open this up and look at that. We have all this information exactly as we wanted it to be made, including all the empty values. And all we had to do was fill in that column information.
At a glance, this is all just words. This is exactly the kind of thing that LLMs are very, very good at updating. If you wanted to say, "Hey, let's change the dates on that from 2026 to 2025," it would be very easy for the LLM to know how to update that.
Okay, I'm getting ahead of myself. Let's add a second one, ipsum, with 15 rows. Let's run that tool. We also have ipsum in this folder. We have a header and 15 rows, same kind of thing, all within our requirements.
Now that we have those, let's check list_workbooks. We see these two are here. And clear_workbooks — run it: "deleted two." Then run it again: "deleted zero." list_workbooks, run it: "no workbooks generated yet." There we have it, exactly as expected. The MCP Inspector shows that these tools are working and provides a way for us to test them.
That is tools. The model now has hands.
In the next section, we're adding the second MCP primitive: resources. If tools are what the model can do, resources are what the model can read. And there are cases where reading beats calling every time.