- Knowledge of JavaScript/TypeScript
- Node (LTS)
- npm (LTS)
See this guide for installation instruction for your OS.
Let's use Hono to get a simple API going.
npm create hono@latestTarget directory: fintech-api
Which template do you want to use? nodejs
Do you want to install project dependencies? Yes
Which package manager do you want to use? npmNow, let's start the API
cd fintech-api/src
npm install
npm run devIf you navigate to http://localhost:3000/ the server will respond:
Hello Hono!
Let's try and identify bots/agents that might be trying to access our API.
We set up a little test in the /agent directory, so open up a new terminal, and navigate over there. agent.ts is a set of tests that will evaluate if you are ready for AI Agents.
To run it run
npm install
npx tsx agent.tsThe first test will try and make an unauthenticated call to your localhost API, and fails if the call goes through without a 401 response.
Test failed: Expected 401 Unauthorized, but got 200Our API currently allows any Agent or bot to call into it, let's use HTTP Message signatures to force them to identify themselves to make a request.
Normally, bot/agent access is governed by robots.txt and the UserAgent header - but any client can spoof their UserAgent. Instead, we need a more robust method to identify AI agents and verify they belong to legitimate entities.
We'll use the web-both-auth package for this. It uses HTTP Message signatures to identify bots and verify their identities.
Kill your hono server and run:
npm install web-bot-authInside index.ts replace the function handler with the following
...
import { verify } from "web-bot-auth";
import { verifierFromJWK } from "web-bot-auth/crypto";
// available at https://github.com/cloudflareresearch/web-bot-auth/blob/main/examples/rfc9421-keys/ed25519.json
const RFC_9421_ED25519_TEST_KEY = {
kty: "OKP",
crv: "Ed25519",
kid: "test-key-ed25519",
x: "JrQLj5P_89iXES9-vFgrIy29clF9CC_oPPsw3c5D0bs",
};
const app = new Hono();
app.get("/", async (c) => {
const rawRequest = c.req.raw;
try {
await verify(rawRequest, await verifierFromJWK(RFC_9421_ED25519_TEST_KEY));
} catch (error) {
return c.newResponse("Failed signature check", {
status: 401,
statusText: "Unauthorized",
headers: {
"Content-Type": "text/plain",
},
});
}
return c.text("Hello Hono!");
});
...Normally, the Signature-Agent header would be used to specify where the public key can be retrieved.
Let's restart the server
npm run devIn your agent terminal, let's run our test again
npx tsx agent.tsTest passed: Unauthorized access correctly blocked.
Test passed: Authorized access correctly granted.Alright, we can now verify that bots traffic is legitimate.
Let's make our API a little bit more real. Let's build a simple API for interacting with Credit Cards and their users.
Before we write an API endpoints, it's important that we have a good understanding of our data model & design. I like using Zod to express my schemas, and will use the integration with Hono to generate an OpenAPI document which will come in handy later. Kill your server abd run the following
npm install @hono/zod-openapiNow let's use Zod to define our schemas. The first one we are going to describe is our card model. Toss the following code in after your imports in index.ts
import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
const CardSchema = z
.object({
id: z
.string()
.openapi({
example: "123",
})
.readonly(),
cardHolderId: z.string().openapi({
example: "1",
}),
cardNumber: z.string().openapi({
example: "4111111111111111",
}),
expiryDate: z.string().openapi({
example: "12/25",
}),
cvv: z.string().openapi({
example: "123",
}),
})
.openapi("Card");Don't worry that createRoute and OpenAPIHono aren't used yet, we will get to them in a second.
After your schema in index.ts let's add the following placeholder data
const CARD_DATA = [
{
id: "1",
cardNumber: "4111111111111111",
cardHolderId: "1",
expiryDate: "12/25",
cvv: "123",
},
{
id: "2",
cardNumber: "5500000000000004",
cardHolderId: "2",
expiryDate: "11/24",
cvv: "456",
},
{
id: "3",
cardNumber: "340000000000009",
cardHolderId: "3",
expiryDate: "10/23",
cvv: "789",
},
{
id: "4",
cardNumber: "6011000000000012",
cardHolderId: "4",
expiryDate: "09/22",
cvv: "321",
},
];Now let's design a new endpoint for the agent to fetch these credit cards
...
const getCards = createRoute({
method: "get",
path: "/cards",
responses: {
200: {
content: {
"application/json": {
schema: z.array(CardSchema).openapi({
example: CARD_DATA,
}),
},
},
description: "Retrieve the cards",
},
},
});Since we are schematizing all of our APIs, we also need to update existing endpoints. First, we need to swap our the server we are using:
Replace
const app = new Hono();with
const app = new OpenAPIHono();Finally, we can hook up our endpoint design to our server.
app.openapi(getCards, (c) => {
return c.json(CARD_DATA);
});Let's see if this thing works. Run it again
npm run devand navigate to http://localhost:3000/cards
Okay, but what was the point of all of this schematization? Well, it comes in handy when validating requests that are trying to put stuff INTO your data store. Let's spin up a new endpoint:
const addCard = createRoute({
method: "post",
path: "/cards",
request: {
body: {
content: {
"application/json": {
schema: CardSchema,
},
},
required: true,
},
},
responses: {
201: {
content: {
"application/json": {
schema: CardSchema,
},
},
description: "Add a new card",
},
},
});
app.openapi(addCard, (c) => {
const card = c.req.valid("json");
CARD_DATA.push(card);
return c.json(card, 201);
});And of course, we need to test this
npx tsx agent.tsTest passed: /cards endpoint returned valid data.
Test passed: New card added successfully.
Test passed: Invalid card rejected as expectedOkay, we have a decent API set up here, but every good API needs documentation. Especially if you want an Agent to be able to understand an access it.
What's nice about the library we are using, is that it actually generates a full OpenAPI document for us. Just add a new route
app.doc("/docs", {
openapi: "3.0.0",
info: {
version: "1.0.0",
title: "My Credit Card API",
},
});Now go to http://localhost:3000/docs and you will get access to a full OpenAPI specification! You can use tools like Zudoku to render your API documentation
Let's clean up the API response. Often, it's unnecessary to send back the entire credit card number. Let's go back to our implementation of getCards and update how we send data back:
app.openapi(getCards, (c) => {
const censorCreditCardNumbers = (card: string) =>
card.replace(/\d(?=\d{4})/g, "*");
const censoredCards = CARD_DATA.map((card) => ({
...card,
cardNumber: censorCreditCardNumbers(card.cardNumber),
}));
return c.json(censoredCards);
});Okay, let's evolve our API a little bit. It's kind of strange that we are giving AI access to all of our credit cards. Often times, AI makes calls on behalf of some users or entity. In this case, it could be on behalf of a credit card customer. Let's add some basic authentication and authorization to our API to keep our data secure.
Let's add a simple object that we will pretend to be our user table:
const USER_DATA = [
{
id: "1",
name: "John Doe",
username: "johndoe",
password: "password123",
},
{
id: "2",
name: "Jane Smith",
username: "janesmith",
password: "password456",
},
];We will have the agent send along a header that will tell us which user is making the call. Let's define this header in Zod:
const BasicAuthSchema = z
.object({
Authorization: z.string().optional().openapi({
example: "Basic <token>",
}),
})
.openapi("BasicAuth");I am going to be using the totally safe and secure Basic Authentication protocol here :)
We need a way of taking a basic auth token and decoding (base64) so here's a function that will do this for you:
const authenticateUser = (header: string) => {
const basicToken = header.split(" ")[1];
if (!basicToken) {
return null;
}
const decodedToken = Buffer.from(basicToken, "base64").toString("utf-8");
const [username, password] = decodedToken.split(":");
const user = USER_DATA.find(
(u) => u.username === username && u.password === password
);
if (!user) {
return null;
}
return user.id;
};Now, let's go back to the implementation of getCards and tailor the response. First, we need to augment our design (getCards) of the endpoint to add the new header:
...
request: {
headers: BasicAuthSchema,
},
...Now, we can modify the implementation. Add the following code before the final return:
...
const authHeader = c.req.header("Authorization");
let userId = null;
if (authHeader) {
userId = authenticateUser(authHeader);
}
if (userId) {
const userCards = censoredCards.filter(
(card) => card.cardHolderId === userId
);
return c.json(userCards);
}
...As always, you can run agent.ts script to verify this works:
npx tsx agent.tsTest passed: User-specific cards fetched successfully.Bonus: See if you can add object level authorization to the addCard function. If a user tries to add a credit card for a different user, send back a Problem Details error message.
Very often, agents will try and perform the same type of task multiple times in a row to automate it. We should make everyone's lives easier by exposing a bulk endpoint. Let's expose one for adding credit cards.
See if you can figure this out before I give you the answer below
please
have
some
patience
and
try
this
on
your
own
OK, here it is
const addCardsBulk = createRoute({
method: "post",
path: "/cards/bulk",
request: {
body: {
content: {
"application/json": {
schema: z.array(CardSchema),
},
},
required: true,
},
},
responses: {
201: {
content: {
"application/json": {
schema: z.array(CardSchema),
},
},
description: "Add multiple new cards",
},
},
});
app.openapi(addCardsBulk, (c) => {
const cards = c.req.valid("json");
CARD_DATA.push(...cards);
return c.json(cards, 201);
});First thing we are going to want to do is get an MCP server set up, Luckily, we can reuse most of our code. Let's get the right packages installed first:
npm install @zuplo/mcpAt the top of your file, add the following imports
import { MCPServer } from "@zuplo/mcp/server";
import { HTTPStreamableTransport } from "@zuplo/mcp/transport/httpstreamable";
import { CustomValidator } from "@zuplo/mcp/tools/custom";From there, you can define your MCP server:
const server = new MCPServer({
name: "Credit Card MCP",
version: "0.0.0",
});
// HTTP Streamable Transport
const transport = new HTTPStreamableTransport();
await transport.connect();
server.withTransport(transport);Now, let's define a tool that does more than a single resource access. It should try and solve a real user problem. How about replacing a credit card?
const replaceCardSchema = z.object({
cardLastFour: z
.string()
.describe("The last four digits of the card to replace"),
basicAuthToken: z.string().describe("The user token for authentication"),
});
server.addTool({
name: "Replace credit card",
description: "Replaces a user's credit card",
validator: new CustomValidator(
z.toJSONSchema(replaceCardSchema),
(input: unknown) => {
const parsed = replaceCardSchema.safeParse(input);
if (!parsed.success) {
return {
success: false,
data: null,
errorData: parsed.error,
errorMessage: "Invalid input data",
};
}
return {
success: true,
data: parsed.data,
errorData: null,
};
}
),
handler: async (params) => {
const { cardLastFour, basicAuthToken } = params;
// Implement the logic to replace the credit card
const authHeader = `Basic ${Buffer.from(basicAuthToken).toString(
"base64"
)}`;
const userId = authenticateUser(authHeader);
if (!userId) {
throw new Error("Unauthorized: Invalid user credentials");
}
const cardIndex = CARD_DATA.findIndex(
(card) =>
card.cardNumber.endsWith(cardLastFour) && card.cardHolderId === userId
);
if (cardIndex === -1) {
throw new Error("Card not found for the user");
}
const newCard = {
id: (CARD_DATA.length + 1).toString(),
cardNumber: "4111111111111111",
cardHolderId: userId,
expiryDate: "12/26",
cvv: "123",
};
CARD_DATA[cardIndex] = newCard;
return {
content: [{ type: "text", text: JSON.stringify(newCard) }],
isError: false,
};
},
});Now we just have to add a new endpoint to serve our MCP server from:
app.post("/mcp", async (c) => {
try {
const request = c.req.raw;
return transport.handleRequest(request);
} catch (error) {
console.error("Error handling MCP request:", error);
return new Response(JSON.stringify({ error: "Internal server error" }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
});Testing MCP is easy using the default toolset. We are going to use MCP Inspector.
npx @modelcontextprotocol/inspectorThis will install the inspector and run it. Make sure your server is running and point the inspector to your endpoint: http://localhost:3000/mcp
Let's replace John Doe's credit card. First, execute List Tools to find our tool. Open it up an enter
1111 for cardLastFour and johndoe:password123 for basicAuthToken and then run the tool!
Restart your server and run the final iteration of the agent.ts script.
npx tsx agent.tsTest passed: MCP tool for replacing card executed successfully.
All Tests Complete: You are now AI Agent ready.Congrats!