ENG- KI-Beschleunigung
- Branchen
- Finanzen
Nearshore-Softwareentwicklung für den Finanzsektor – sicher, skalierbar und Compliance-gerechte Lösungen für Banking, Zahlungsverkehr und APIs.
- Einzelhandel
Softwareentwicklung für den Einzelhandel – E-Commerce, Kassensysteme, Logistik und KI-gestützte Personalisierung durch unsere Nearshore-Engineering-Teams.
- Verarbeitende Industrie
Nearshore-Softwareentwicklung für die Industrie – ERP-Systeme, IoT-Plattformen und Automatisierungstools zur Optimierung industrieller Abläufe.
- Finanzen
- Was wir tun
- Dienstleistungen
- Technologien
- Collaboration models
- Kooperationsmodelle
Kooperationsmodelle passend zu Ihren Bedürfnissen: Komplette Nearshoring Teams, deutschsprachige Experten vor Ort mit Nearshoring-Teams oder gemischte Teams mit unseren Partnern.
- Arbeitsweise
Durch enge Zusammenarbeit mit Ihrem Unternehmen schaffen wir maßgeschneiderte Lösungen, die auf Ihre Anforderungen abgestimmt sind und zu nachhaltigen Ergebnissen führen.
- Kooperationsmodelle
- Über uns
- Wer wir sind
Wir sind ein Full-Service Nearshoring-Anbieter für digitale Softwareprodukte, ein perfekter Partner mit deutschsprachigen Experten vor Ort, Ihre Business-Anforderungen stets im Blick
- Unser Team
Das ProductDock Team ist mit modernen Technologien und Tools vertraut und setzt seit 15 Jahren zusammen mit namhaften Firmen erfolgreiche Projekte um.
- Wozu Nearshoring
Wir kombinieren Nearshore- und Fachwissen vor Ort, um Sie während Ihrer gesamten digitalen Produktreise optimal zu unterstützen. Lassen Sie uns Ihr Business gemeinsam auf das nächste digitale Level anheben.
- Wer wir sind
- Unser Leistungen
- Karriere
- Arbeiten bei ProductDock
Unser Fokus liegt auf der Förderung von Teamarbeit, Kreativität und Empowerment innerhalb unseres Teams von über 120 talentierten Tech-Experten.
- Offene Stellen
Begeistert es dich, an spannenden Projekten mitzuwirken und zu sehen, wie dein Einsatz zu erfolgreichen Ergebnissen führt? Dann bist du bei uns richtig.
- Info Guide für Kandidaten
Wie suchen wir unsere Crew-Mitglieder aus? Wir sehen dich als Teil unserer Crew und erklären gerne unseren Auswahlprozess.
- Praktikum im Anfänger-Bootcamp
Starte deine IT-Karriere mit dem Rookie Boot Camp, unserem bezahlten Praktikumsprogramm, in dem Studenten und Absolventen Fähigkeiten aufbauen, Selbstvertrauen gewinnen und praktische Erfahrungen sammeln.
- Arbeiten bei ProductDock
- Newsroom
- News
Folgen Sie unseren neuesten Updates und Veröffentlichungen, damit Sie stets über die aktuellsten Entwicklungen von ProductDock informiert sind.
- Events
Vertiefen Sie Ihr Wissen, indem Sie sich mit Gleichgesinnten vernetzen und an unseren nächsten Veranstaltungen Erfahrungen mit Experten austauschen.
- News
- Blog
- Kontakt
03. Sep. 2026 •9 minutes read
I let an AI agent call my REST API: Here’s what I had to fix
Danijel Dragičević
Software Engineer
What really changes in your code when the next caller of your API is an LLM rather than a traditional client?
The question I couldn’t stop asking
I had a small appointment-scheduling API sitting around: Express, TypeScript, in-memory store, the kind of unglamorous service every company has a dozen of. It already did the normal things well: real auth, sane error codes, a decent OpenAPI spec, tests that actually caught bugs. By any conventional measure, it was done.
Then I asked myself one question I couldn’t shake: if an AI agent showed up as a client tomorrow, not a browser, not a script I wrote, but an actual autonomous agent trying to book an appointment on someone’s behalf, would this thing survive contact?
I didn’t know. So instead of guessing, I dug up two resources from Postman: The 2025 State of the API Report (I’ll call it “the Report”), and the companion 90-Day AI Readiness Playbook (“the Playbook”), and used them as a second opinion while I went looking for the gaps myself.
It turns out I wasn’t the only one asking. The Report opens with a statistic that stopped me: “89% of developers already use generative AI daily, but only 24% design their APIs with AI agents in mind” (Report, pp.7–8). And the Playbook frames the stakes more openly than I would have: “Your APIs are either part of this fundamental shift or an obstacle to it” (Playbook, p.1). Reading it next to my own project felt less like research and more like being caught.
What follows is the story of what I actually found and changed. Four real problems, in order of how I ran into them, with the code and terminal output to show for it.
1. Reading my own spec like a stranger would
The first thing I did wasn’t write code. I opened my API spec and tried to read it the way an agent would. No memory of writing it, no context, just the words on the page, and one shot to get a request right.
Mostly, it held up. But a few fields just described shape, not rules: technically true, useless in practice.
# What I originally had - tells you the shape, not the rules
date:
type: string
format: date
A human reading the spec would assume “any reasonable date” and move on. An agent reading it has no way to know the API will reject yesterday’s date or a date 12 months out until it tries and gets a 400 error code back. That’s a wasted call. It’s also the kind of repeated, unexplained retries that’s hard to tell apart from outside abuse.
The Playbook happened to be making the same point. It contrasts a plain-English comment (“This endpoint accepts a user ID and returns user preferences“) against the same endpoint written as a real OpenAPI operation, with typed parameters, a schema-backed 200 success response, a documented 404 error code, and a concrete example. Conclusion is: the prose version works for humans but not for machines, while the full version leaves nothing to interpretation or prior knowledge. This realization reframed the whole exercise for me. The bar isn’t whether something is documented, it’s whether a caller with zero prior context can act on it correctly on the first try.
To address this, I rewrote the fields that were hiding rules instead of stating them:
date:
type: string
format: date
description: >
Must be a real calendar date in YYYY-MM-DD format, no earlier
than today and no more than a year from today.
Every error response in the spec got the same treatment. The BadRequest response, for instance, now spells out all four distinct reasons a booking could be rejected in one place, instead of forcing a caller to discover them one by one through failed requests. It’s a small change per field, but it adds up to a spec an agent can actually plan around instead of probe.
2. The moment I realized my auth token couldn’t tell anyone apart
Here’s the part that actually bothered me. This API already had bearer-token auth: one shared token, checked on every request, fine for a demo. But re-reading the auth middleware, I realized the real problem wasn’t the shared secret. It was that the middleware had no way to distinguish a human typing a curl command from a script hammering the same endpoint 200 times a second.
I went looking to see how big a deal this lack of visibility actually is, and the Report had already measured it: “Unauthorized or excessive API calls from AI agents is developers’ number one security worry, ahead of data exposure or leaked credentials, at 51%” (Report, p.9). Right next to that number is the reason why: “If you can’t tell a human from an agent, you can’t enforce least privilege, detect abuse, or meet compliance requirements” (Report, p.9). I couldn’t. So I fixed that first, before touching anything else.
The fix didn’t need a new identity system: just a second bearer token, and a reason to use it.
// src/utils/authToken.ts (core of it)
export function isAgentToken(token: string | undefined): boolean {
return Boolean(process.env.AGENT_API_TOKEN) && token === process.env.AGENT_API_TOKEN;
}
// src/utils/clientType.ts
export type ClientType = "agent" | "default";
export function getClientType(req: Request): ClientType {
return isAgentToken(extractBearerToken(req)) ? "agent" : "default";
}
That classification now feeds a two-tier rate limiter, and every log line the app already writes:
// src/middleware/rateLimiter.ts
export function createRateLimiter(options: RateLimiterOptions = {}): RateLimitRequestHandler {
const { windowMs = 60_000, unidentifiedLimit = 60, identifiedLimit = 150 } = options;
return rateLimit({
windowMs,
limit: (req) => (getClientType(req) === "default" ? unidentifiedLimit : identifiedLimit),
standardHeaders: true,
legacyHeaders: false,
message: { error: "Too many requests, please try again later" },
});
}
No separate authorization layer: whichever bearer token you already hold determines your tier. The deal is simple: if you are authenticated with the agent token you get 150 requests a minute instead of 60.
export API_TOKEN=local-dev-token # match whatever's in your dev.env
# Fire off 61 plain requests in a row and print the status of each one.
for i in $(seq 1 61); do
code=$(curl -s -o /dev/null -w "%{http_code}" \
http://localhost:8080/services \
-H "Authorization: Bearer $API_TOKEN")
echo "request $i -> $code"
done
The first 60 lines all say 200 (status code). The 61st says something else:
request 58 -> 200
request 59 -> 200
request 60 -> 200
request 61 -> 429
That’s the default ceiling. To see the other one, I ran the same loop again, authenticating with the agent token instead, and pushed the count past 61:
export AGENT_API_TOKEN=local-dev-agent-token # match whatever's in your dev.env
for i in $(seq 1 151); do
code=$(curl -s -o /dev/null -w "%{http_code}" \
http://localhost:8080/services \
-H "Authorization: Bearer $AGENT_API_TOKEN")
echo "request $i -> $code"
done
Same machine, same loop shape, but this time it doesn’t stop at 61:
request 148 -> 200
request 149 -> 200
request 150 -> 200
request 151 -> 429
The API can now distinguish and price accordingly, rather than treating every caller as an anonymous, equally untrusted one. Also, I didn’t have to build any new observability to see the difference in the logs, either. It was already sitting there:
GET /services 429 53 - 0.181 ms [client=default]
GET /services 200 164 - 0.191 ms [client=agent]
Booked appointment #3 for John Doe with Dr. White on 2026-07-28 at 11:00 [client=agent]
The third line isn’t an access log; it’s the same business-event line the app was already writing, now with the tag attached for free. If I ever need to answer “did a person book this, or something automated,” the answer is sitting in a log file I already have, not a tracing system I’d need to build.
3. Building the door I knew agents would eventually knock on
With the auth gap settled, I got to the part I’d been putting off: MCP. Not because it’s hard, but because it felt optional right up until I read this line from the Report and realized it wasn’t:
“Agents are already calling your APIs, with or without MCP. […] If your interface isn’t AI agent-ready, every team builds one-off wrappers that break, leak secrets, and waste time.” (Report, p.15)
The Report’s warning actually moved me. The choice was never whether to build an MCP server or not. It was to build one good one, or watch every consumer build their own version of the same idea.
The first decision I made was about trust, not protocol: the MCP server doesn’t access the app’s controllers or data store. It’s just another authenticated client of the real running API, calling it over plain fetch:
// src/mcp/client.ts (core of it)<br>export async function apiRequest<T>(method: string, path: string, body?: unknown): Promise<T> {<br> const res = await fetch(`${baseUrl()}${path}`, {<br> method,<br> headers: {<br> "Content-Type": "application/json",<br> ...authHeader(), // Authorization: Bearer <AGENT_API_TOKEN><br> },<br> body: body === undefined ? undefined : JSON.stringify(body),<br> });<br> // ...surfaces the API's real {error} message on failure<br>}
That AGENT_API_TOKEN line is doing more work than it looks like: it’s the moment sections 2 and 3 of this story actually meet. The MCP server isn’t a special backdoor with elevated trust; it uses the exact same agent credential as in section 2, so it’s rate-limited and logged like anything else calling the API.
There’s one more thing worth mentioning. A local MCP server is just a program running on your own machine, and that’s also a risk: a bad startup command, a compromised package, or some other program on the same machine could try to talk to it uninvited. The MCP spec’s own security best-practices guide calls this out directly, and its recommended fix is simple: “Use the stdio transport to limit access to just the MCP client.” It is the same transport I was already using, for the simple reason that it’s the SDK’s default. Turns out it also closes this door: there’s no open port on the machine for anything else to find.
The second decision took longer to talk myself into: I could have generated one MCP tool per REST endpoint straight from the spec in about 10 minutes and moved on. The same “explains why, not just what” bar from section 1 applies here too (Report, p.15): a mechanical dump of endpoints has no opinion on sequence or intent. So I wrote six tools by hand instead, each carrying judgment a generated wrapper never would:
// src/mcp/tools.ts
server.registerTool(
"book_appointment",
{
description:
`Book a new appointment. Before calling this: get the exact user name from list_users, the exact service name from list_services, and an open provider/time from check_availability. The API also rejects dates before today or more than a year out.`,
inputSchema: {
user: z.string().describe("Exact name of an existing user (see list_users)."),
provider: z.string().describe("Provider name (see check_availability)."),
service: z.string().describe("Exact name of an existing service (see list_services)."),
time: z.string().describe("24-hour time in HH:MM format, matching an open slot from check_availability."),
date: z.string().describe("Date in YYYY-MM-DD format, today through one year from today."),
},
},
async (input) => {
try {
return textResult(await apiRequest("POST", "/appointments", input));
} catch (error) {
return errorResult(error);
}
}
);
This tool description is doing the same job as a good onboarding doc does for a new hire: the difference between an agent who checks availability first and one who burns through three failed calls rediscovering the rules by trial and error. Registered tools cover the whole booking flow, and when I wired up a real MCP client and pointed it at the running server, it worked exactly like I’d hoped:
TOOLS: [ 'list_users', 'list_services', 'check_availability',
'book_appointment', 'list_appointments', 'cancel_appointment' ]
book_appointment: {
"id": 3, "user": "John Doe", "provider": "Dr. White",
"service": "Consultation", "time": "11:00",
"date": "2026-07-28", "status": "confirmed"
}
book_appointment (bad user) isError: true -> Unknown user "Nobody"
The last line is the one I actually cared about seeing. The agent didn’t get a stack trace or a mysterious failure; it got the exact same “Unknown user” error a human client would, because it went through the same validation path as everything else. Nothing about arriving through MCP buys it a shortcut around the rules.
I didn’t stop at the smoke test, either. A few days later, I pointed a real reasoning agent (Claude Code session) at the running server and asked it, in plain English, to book me an appointment under my own name. The first thing it ran into wasn’t a code bug at all:
There's no registered user named "Danijel Dragicevic" — only John Doe and Jane Doe exist in the system, and there's no tool available to create a new user. Would you like to book this Consultation @ 11:00 under John Doe or Jane Doe instead?
I hadn’t thought about that scenario when I wrote the tools; I’d assumed whoever tested them would use an existing name. But the agent hits the same referential-integrity check every human caller hits, reads the real error, and asks instead of guessing or inventing a user. The missing create-user option is deliberate, too: the REST API already supports creating users, but I didn’t expose that as a tool. Letting an autonomous agent create identity records on its own is a bigger trust boundary than looking something up, worth deciding on purpose rather than by default.
It also exposed something I’d been sloppy about. I asked the same agent to show me details about registered users, and got back a clean little table: ID, name, email, phone, all of it.
The list_users tool had been a thin pass-through of GET /users. I’d never stopped to ask what the tool actually needed to do its job. One thing: match a name, so book_appointment can validate it. Not identity or full contact details of the users.
The agent didn’t misuse anything; it didn’t have to: the “can’t enforce least privilege” problem from section 2 was back, this time somewhere I hadn’t thought to look.
The fix was small, and it belonged entirely in the tool, not the API:
// src/mcp/tools.ts<br>async () => {<br> try {<br> const users = await apiRequest<User[]>("GET", "/users");<br> return textResult(users.map(({ id, name }) => ({ id, name })));<br> } catch (error) {<br> return errorResult(error);<br> }<br>};
GET /users itself remains untouched: a real, authorized caller still gets full records. The MCP tool just stopped forwarding beyond the one job it was required to handle. Same agent, same kind of request, after the fix:
The `list_users` tool only exposes id and name — no other details are available through this MCP server. [...]
4. The nagging doubt I couldn’t ignore
By this point, I had three new moving parts (richer docs, a rate limiter, an MCP server) and one question that wouldn’t leave me alone: what happens six months from now, when someone changes an error message in the code and forgets the YAML exists? Specs don’t drift on purpose. They drift because nothing’s watching.
The Report gave me a number that made the decision easy: only 17% of teams do contract testing (Report, p.23). The Playbook calls the same idea a prerequisite rather than a nice-to-have. Quality gates are automated checks that validate API specifications against actual behavior before anything downstream can trust the spec.
So I added the smallest version of that I could get away with: a handful of tests that dereference the real spec and check it against real responses, not mocked ones.
// src/test/contract/contract.test.ts
it("POST /appointments with a conflicting slot matches its 409 schema", async () => {
const res = await authed.post("/appointments").send({
user: "Jane Doe",
provider: "Dr. Smith",
service: "Check-up",
time: "09:00",
date: TODAY,
});
expect(res.status).toBe(409);
await expectMatchesSchema(res.body, "/appointments", "post", 409);
});
Seven cases, covering every response shape the API actually uses, now run in CI on every push. It’s not exciting work. It’s the reason everything I did in the first three sections holds true even today.
What I actually walked away with
Nothing about this API’s business logic changed. A booking is still rejected for the same reasons it always has been. What changed is whether a caller with no memory and no patience can succeed on the first try, be told clearly why it didn’t, and trust that the spec they read matches the code they’re hitting.
None of it was hard. It was four decisions, made one at a time, each one because something I ran into demanded an answer. The Report’s closing line captures the stakes better than I could: “Organizations must choose to embrace API-first development and AI-readiness or risk falling behind as competitors build more adaptive, secure, and profitable API ecosystems” (Report, p.25).
The full project is on GitHub. Clone it, run the same curl loops, point your own agent at it, and see if it holds up. If you spot a gap I missed or want to take any of this further, pull requests and issues are welcome.
If you found this approach interesting or see a similar opportunity to optimize your own infrastructure for AI agents, we would love to help you navigate these challenges. Please get in touch to discuss how we can support your specific use case.
Tags:Skip tags
Danijel Dragičević
Software EngineerDanijel Dragičević is a software developer and content creator who has been part of our family since April 2014. With a strong background in backend development, he has spent the past few years specializing in building robust services for API integrations. Passionate about clean code and efficient workflows, he continuously explores new technologies to enhance development processes.