# AI-native isn't a chatbot. It's an MCP server.

> Source: <https://dev.to/brthls/ai-native-isnt-a-chatbot-its-an-mcp-server-2ipg>
> Published: 2026-07-20 23:57:37+00:00

Open any product changelog from the last year and you'll find the same move: a chat bubble in the corner, a sparkle icon, a launch post with the word AI in the headline. The widget answers questions about the product. Sometimes it drafts an email. Everyone ships it, everyone calls it AI-native, and almost everyone is solving the wrong layer.

A chat widget is a *conversation about* your product. It sits on top of the UI and paraphrases your docs. The model never touches your domain. Ask it to actually do the thing — issue the credit note, reconcile the bank line, file the quarterly return — and it hands you a link and wishes you luck. You didn't build an AI-native product. You bolted a support bot onto a CRUD app.

The tell is simple: if you removed the chat widget, would the software be any more or less operable by an agent? If the answer is no, the AI is decoration.

Stop asking "can a user chat about my product." Ask "can an agent *operate* my product."

Operate means run state-changing work against live data, unattended, with the same guarantees your API gives a first-class integration. Not "summarize my invoices." Create one, email it, mark it paid, and issue a correction if the client disputes it — as discrete, callable actions with typed inputs and typed results.

That reframing moves the work down a layer, out of the presentation tier and into your domain. And it exposes an uncomfortable fact: most products have no clean surface for a caller that isn't a human clicking buttons or a bespoke integration someone hand-wired. The agent needs something in between — narrow, typed, safe to call, safe to retry.

The pattern: expose each domain operation as a **tool** — a named function with a typed input schema, a typed output schema, and a one-line description the model reads to decide when to call it. The model doesn't parse your HTML or guess your REST routes. It sees a catalog of capabilities and picks one.

This is what the Model Context Protocol (MCP) standardizes. Instead of every product inventing its own agent interface, MCP defines a transport and a shape: `tools`

, `resources`

, and `prompts`

— all discoverable, with tools callable, over stdio or Streamable HTTP. Write your domain as MCP tools once, and any MCP-capable client can drive it.

The shape of the pattern, not production code:

```
server.registerTool(
  "create_invoice",
  {
    description:
      "Create a draft invoice for a client with line items. Returns the invoice id and computed totals.",
    inputSchema: {
      clientId: z.string(),
      currency: z.enum(["EUR", "USD"]),
      lines: z
        .array(
          z.object({
            description: z.string(),
            quantity: z.number().positive(),
            unitPrice: z.number().nonnegative(),
            taxRate: z.number().min(0).max(1),
          }),
        )
        .min(1),
      idempotencyKey: z.string().uuid(),
    },
    outputSchema: {
      invoiceId: z.string(),
      status: z.literal("draft"),
      subtotal: z.number(),
      taxTotal: z.number(),
      total: z.number(),
    },
  },
  async ({ clientId, currency, lines, idempotencyKey }, extra) => {
    const actor = await requireScope(extra, "invoices:write"); // per-agent auth
    const out = await invoices.create({ clientId, currency, lines, idempotencyKey, actor });
    return {
      content: [{ type: "text", text: JSON.stringify(out) }],
      structuredContent: out, // validated against outputSchema before it leaves the server
    };
  },
);
```

The description is not a comment — it's part of the contract. The model reads it to route. The schemas are not documentation — they're the guardrails that keep a probabilistic caller inside a deterministic system. And note the return shape: the SDK validates `structuredContent`

against your `outputSchema`

before it leaves the server. That validation is the whole point.

The tool registration is the easy 20%. The 80% is everything that makes an agent-driven call safe, and this is where most AI-native launches quietly fall apart.

**Per-agent auth and scoping.** A human session and an agent session are not the same threat model. Each agent identity needs its own credential and its own scope — `invoices:write`

should not imply `payroll:read`

. Bearer keys or an OAuth 2.1 authorization-code flow with PKCE both work; what matters is that the tool layer checks scope *per call*, not per connection, and that you can revoke one agent without nuking the others.

**Idempotency.** An agent will retry. It times out, it re-plans, it fires the same call twice because a network blip ate the response. If `create_invoice`

isn't idempotent, your customer now has two invoices and one very confused client. Every state-changing tool needs an idempotency key and server-side dedup — the boring plumbing that human UIs get away with skipping because humans don't retry at machine speed.

**Typed contracts.** The `outputSchema`

is what lets the model chain calls without hallucinating the shape of your data. Return structured JSON, not prose. For list operations, return real pagination — `{ data, total, limit, offset }`

— so the agent can page deterministically instead of guessing whether it saw everything.

**Audit trail.** When a non-human actor mutates financial or fiscal state, "who did this and why" is not optional. Every tool call needs an actor, a timestamp, and enough context to reconstruct the decision. This is also your incident-response surface when an agent does something dumb — and it will.

**Deterministic, recoverable errors.** A stack trace is useless to a model. A well-designed error tells the agent what went wrong *and what to do next*: `client_not_found`

(go create the client first), `rate_limited`

(back off), `validation_failed`

with the offending field. The model can recover from a typed error. It cannot recover from a 500 and a wall of HTML.

Get these five right and you have an agent-native product. Skip them and you have a demo that works once on stage.

At Frihet — an AI-native ERP — we treat the MCP server as a first-class product surface. It's open source at `github.com/Frihet-io/frihet-mcp`

, ships on npm as `@frihet/mcp-server`

, and exposes **157 tools** across the ERP domain: invoices, expenses, clients, CRM, products, quotes, banking, e-invoicing, time tracking, payroll, period close, and Spanish and Canary Islands tax models. Every tool is a CRUD operation over the REST API that returns typed JSON via an `outputSchema`

; list tools return paginated `{ data, total, limit, offset }`

.

An agent can create an invoice with line items, email it as a PDF, mark it paid, and issue a credit note if the client disputes it — one chained flow, four discrete tools. It can log and search expenses, draft and send quotes, categorize bank transactions and match them to invoices.

The fiscal surface is where the domain depth shows. It prepares Spanish tax filings — quarterly IVA (Modelo 303) and IRPF (Modelo 130), the annual 390 and 347 summaries — plus IGIC for the Canary Islands. It checks VeriFactu submission status and resubmits rejected records, generates EN16931 e-invoices, and covers TicketBAI.

The auth model is the one from the hard-parts section: an API key as a Bearer token (keys are prefixed `fri_`

), or a browser OAuth login that provisions one — pick per client. That's not incidental. It's the difference between a demo and something you'd let run against production books.

The point isn't the tool count. It's that the domain is exposed as typed, structured, paginated operations — so an agent operates the ERP, it doesn't chat about it.

A short checklist. If you can't answer yes, you have a chat widget, not an AI-native product.

AI-native isn't a layer you paint on top. It's the interface underneath — your domain, exposed as tools an agent can call. Build that surface and the chatbot becomes a rounding error. Skip it and the chatbot is all you ever had.
