In the previous post gives a tutorial how to write a very simple MCP server with Node.js & TypeScript. That server says hello, world!
and adds two numbers.
Today we're going deeper going to write a few MCP tools that talk to a SQL database. We'll also learn how to return a JSON data and see how powerful LLMs become with MCP servers.
I assume that you are already a bit familiar with creating MCP serves using the offical TypeScript SDK. If not, have a look at the previouos tutorial.
We'll also be quering SQLite, but no knowledge of it or SQL is really necessary to understand what is going on.
The same as in the previous post, but we'll aslo need the better-sqlite3
and @types/better-sqlite3
package for SQLite. The database itself is here in GitHub.
Let's first set the context. Assume we're a small wine merchant. We keep track of our wine and sales using a SQLite database. It's a simple database that has only two table:
wine
- basic information about wine
id
, name
, type
, price
orders
- a list of orders for each wine
id
, created_at
, wine_id
, quantity
list-wine
Let's write the first MCP tool that simply return a list of wine. Also let's make it return only the id
and name
of each wine. The other details will be returned via a different MCP tool. It'll make for a great example later.
./src/index.ts
server.registerTool(
"list-wines",
{
title: "list all wines we're selling",
description: "list all wines we're selling",
},
async () => {
// import Database from "better-sqlite3";
const db = new Database("./data/wine.db");
const wineList = db.prepare("SELECT id, name FROM wine").all();
return {
content: [
{
type: "text",
text: JSON.stringify(wineList),
},
],
};
}
);
Let's register the server with Claude Desktop. And ask it for which wines we have using this prompt:
Which wine do we sell?
And we should get the following result back:
What's interesting here is that our MCP tool returns a basic JSON array looking like:
[
{
"id": "0678950a-23cd-42a9-b2b3-8900404a6eaa",
"name": "Château Margaux 2015"
},
{ "id": "7ba569d7-2b7f-49fb-b4a4-5bf52b60015d", "name": "Dom Pérignon 2012" }
]
While Claude Desktop added more flavour to the response.
You may have noticed that we return the following in the tool:
return {
content: [
{
type: "text",
text: JSON.stringify(wineList),
},
],
};
That's a requirement of the MCP specification that literally states the following:
For backwards compatibility, a tool that returns structured content SHOULD also return the serialized JSON in a TextContent block.
We can also return a JSON properly as JSON and describe it in the outputSchema
:
erver.registerTool(
"list-wines",
{
title: "list all wines we're selling",
description: "list all wines we're selling",
outputSchema: {
wineList: z.array(
z.object({
id: z.string().describe("The id of the wine"),
name: z.string().describe("The name of the wine"),
})
),
},
},
async () => {
const db = new Database("./data/wine.db");
const wineList = db.prepare("SELECT id, name FROM wine").all();
return {
// for back compatibility
content: [
{
type: "text",
text: JSON.stringify({ wineList }),
},
],
structuredContent: { wineList },
};
}
);
Due to the limitations of the TypeScript SDK we cannot simply return an array. Instead we return a JSON object that has wineList
which an array of wine. If we re-run it in Claude Desktop it will still work.
We've purposfully skipped a few fields in the wine
table from the list-wine
tool. We don't return type
and price
. That's to provide an interesting example.
get-wine-by-id
:server.registerTool(
"get-wine-by-id",
{
title: "Gets a wine by id",
description: "Gets a wine by id",
inputSchema: {
id: z.string().describe("The id of the wine"),
},
outputSchema: {
id: z.string().describe("The id of the wine"),
name: z.string().describe("The name of the wine"),
type: z.string().describe("The type of the wine"),
price: z.number().describe("The price of the wine"),
},
},
async (input) => {
const db = new Database("./data/wine.db");
const wine = db
.prepare("SELECT * FROM wine WHERE id = ?")
.get(input.id) as {
id: string;
name: string;
type: string;
price: number;
};
return {
// for back compatibility
content: [
{
type: "text",
text: JSON.stringify(wine),
},
],
structuredContent: wine,
};
}
);
The tool builds on what we have already learnt:
id
inputSchema
outputSchema
Now let's do the following:
Which wine do we sell?
Opus One 2018
. I don't know about you. Personally I've never heard about that wine before.Give me more information on Opus
What happened here is truly magical 💫! Let's take it step by step:
list-wine
toolid
and name
Opus One 2018
get-wine-by-id
Give me more information on Opus
Opus One 2018
Opus
Opus One 2018
from the call to list-wine
when we just say Opus
list-wine
resultsget-wine-by-id
We have another table in our database - orders
. Let's return data from it and extend out get-wine-by-id
MCP tool.
server.registerTool(
"get-wine-by-id",
{
title: "Gets a wine by id including sales data",
description: "Gets a wine by id including sales data",
inputSchema: {
id: z.string().describe("The id of the wine"),
},
outputSchema: {
id: z.string().describe("The id of the wine"),
name: z.string().describe("The name of the wine"),
type: z.string().describe("The type of the wine"),
price: z.number().describe("The price of the wine"),
sales: z.number().describe("The sales of the wine"),
},
},
async (input) => {
const db = new Database("./data/wine.db");
const wine = db
.prepare(
`SELECT
wine.*
,SUM(orders.quantity * wine.price) as sales
FROM
wine
LEFT JOIN
orders
ON
wine.id = orders.wine_id
WHERE wine.id = ?
GROUP BY wine.id`
)
.get(input.id) as {
id: string;
name: string;
type: string;
price: number;
sales: number;
};
return {
// for back compatibility
content: [
{
type: "text",
text: JSON.stringify(wine),
},
],
structuredContent: wine,
};
}
);
They key changes:
title
and description
of the tool to highlight that the endpoint now returns sales data.Let's start a new conversation and a get a list of wine again.
After we'll ask Claude the following:
What sells better Opus or Dom?
And Claude will come back with this magic:
So what happened here?
get-wine-by-id
twice - one for each wineAnd we've achived that all by creating only two MCP tools
list-wines
- to get a list of all wineget-wine-by-id
- to retrieve one specific oneWe haven't created any dedicated endpoints for getting a list of sales data or comparing wine. Claude has figured out what to do with very limited tools. Isn't it magic?
We can ask Claude to plot that data on a chart, can't we?
Let's prompt it:
Now show it on a pie chart
More magic
Here Claude a bit of JavaScript and React to visualise our data!
Again we didn't have to do anything extra!
The opinions expressed herein are my own personal opinions and do not represent my employer's view in any way. My personal thoughts tend to change, hence the articles in this blog might not provide an accurate reflection of my present standpoint.
© Mike Borozdin