← Case studies

Building the Laayo connector for Claude.

A Go MCP server that lets people order Laayo food from Claude: narrow tools, prices decided by the server, and an OAuth server built into the same binary.

Sakshyam Baral··10 min readmcpaigosecurity

You can open Claude, connect Laayo, and say "find me momo nearby and send it home". Claude finds the restaurant, builds the cart, shows you the bill, and places the order once you confirm. The connector behind this is a small Go service speaking the Model Context Protocol (MCP), with its own OAuth authorization server built into the same binary. It is listed in Claude's connector directory, so a customer can search for Laayo and tap Connect instead of pasting a URL.

This case study is about the design choices that make ordering through an AI safe: what the tools look like, who decides prices, how sign-in works, and what I got wrong along the way.

The problem and the constraints

An AI that can place food orders is a strange new kind of client. It is not a person tapping buttons, and it is not a script with fixed inputs. It reads tool descriptions, decides what to call, and sometimes calls things in the wrong order, retries after a timeout, or makes up an argument that looks plausible. Any tool I expose has to stay safe when the caller is confident and wrong.

The constraints:

  • Money and real deliveries. An order sends a rider out and a restaurant starts cooking. A duplicate or mispriced order costs real people time and money.
  • No new money paths. Laayo's payment and settlement logic is the most carefully guarded part of the system (see the ledger case study). The connector must not become a second, less-tested way to move money.
  • Customers in Nepal on phones. Sign-in has to work with a Nepali mobile number and an SMS code, on a phone browser, with no Laayo-specific setup.
  • One developer. I had built an earlier version in TypeScript as a separate service with its own dependencies, then removed it. When I came back to this, I wanted it in Go, in the same repository as the rest of the backend, using the same libraries and the same deploy process.
  • Directory review. A listed connector is reviewed. It needs proper OAuth metadata, accurate tool annotations, a working reviewer account, and clear behaviour around anything destructive.

What I built

tool callClaudeuser asks for foodMCP server (Go)tools + OAuthSearchrestaurants, dishesCart + ordersame rules as the appLaayo APIauth, prices, ledger
The model only gets narrow tools; prices and payments are always decided by the server.

One Go binary does three jobs:

  1. The MCP endpoint. It uses Streamable HTTP in its stateless form: every request carries the bearer token, every answer is plain JSON, and there is no event stream and no server-side session. Any instance can answer any request, which keeps operations simple.
  2. An OAuth 2.1 authorization server. It covers discovery metadata, client registration, the authorize and consent pages, the token endpoint and revocation.
  3. A client of Laayo's own customer API. The connector never reads the database. It calls the same customer API the app and website use, as a signed-in customer. Every business rule that applies in the app applies here automatically: delivery zones, opening hours, minimum order, which payment methods a customer may use, and cancellation windows.

That third point is the most important design decision in the project. The connector is an adapter, not a backend. It adds no money logic of its own, so there is nothing new to get wrong.

How it works in detail

Narrow tools. The tools map to steps a customer would take in the app, each doing one thing: list my saved addresses, find restaurants, get a menu, get a dish's options, view the cart, add, change or remove a cart line, clear the cart, preview the bill, place the order, list my orders, track an order, cancel an order. There is no general "call the API" tool and no "order this text" tool that hides several steps. Narrow tools are easier for the model to use correctly, easier for a reviewer to reason about, and easier to annotate honestly.

Each tool carries MCP annotations: read-only, destructive, idempotent, open-world. Viewing the cart is read-only. Removing a cart line is destructive but idempotent. Placing and cancelling an order are destructive and open-world, because they affect people outside the conversation.

Strict inputs. Every input schema sets additionalProperties: false and bounds every number and string. The decoder refuses unknown fields, so a misspelled argument is an error rather than a silently ignored option:

// decode refuses unknown fields: a typo must never silently mean "default".
func decode[T any](raw json.RawMessage) (T, error) {
	var v T
	dec := json.NewDecoder(bytes.NewReader(raw))
	dec.DisallowUnknownFields()
	if err := dec.Decode(&v); err != nil {
		return v, userError("Invalid arguments: " + clip(err.Error()))
	}
	return v, nil
}
// decode refuses unknown fields: a typo must never silently mean "default".
func decode[T any](raw json.RawMessage) (T, error) {
	var v T
	dec := json.NewDecoder(bytes.NewReader(raw))
	dec.DisallowUnknownFields()
	if err := dec.Decode(&v); err != nil {
		return v, userError("Invalid arguments: " + clip(err.Error()))
	}
	return v, nil
}

The server decides prices, and the customer confirms them. The model never computes or sends a price that Laayo will charge. The bill preview tool asks Laayo's own quote endpoint, the one checkout uses, to price the cart for a chosen address. It returns items, discounts, delivery fee, VAT, total and delivery time, plus any reason the order cannot go through, such as the restaurant being closed or the order being below the minimum. The tool description tells the model to show this to the customer.

Placing an order then requires two things from the model:

  • a customer_confirmed flag whose schema allows only true, so the model has to assert, in a field a reviewer can see, that the customer confirmed this order in the conversation;
  • an expected_total: the total the customer agreed to.

The server prices the cart again at the moment of placing. If the fresh total differs from the expected one by more than rounding, nothing is placed and the tool returns the new total with an instruction to show it to the customer and ask again. A price that changed between preview and confirmation, because a discount ended or the cart was edited in the app, can never be charged without the customer seeing it.

No payment inside the chat. From a conversation, the connector places only cash-on-delivery orders. If the customer wants to pay online, the place tool does not place anything. It returns a link to Laayo's web checkout, where the same cart is waiting and the customer pays on Laayo's own page. Card and QR payments stay in the flows that were built and tested for them, and the AI never handles a payment.

Retries never double an order. Models retry, and networks time out. The place tool derives an idempotency key from the customer, the cart lines, the delivery location, the payment method and a coarse time window, and sends it with the order request. A retried call with the same cart in the same window replays the original result. If the order is still being placed, the tool says so. If Laayo does not confirm in time, the tool tells the model to check the order list before trying again, rather than inviting a blind retry. Placing is also rate-limited per customer.

Answers in words. Tool results use plain labels ("Cash on delivery", "Not paid yet") and Nepal-time timestamps rather than internal codes and UTC strings. The model repeats what it reads, so whatever goes into a tool result is effectively customer-facing copy.

Sign-in: an OAuth server in the same binary

Claude needs to act as a specific Laayo customer, so the connector runs a full OAuth flow. I built the authorization server into the connector instead of adding it to the main backend. That keeps the token logic for AI clients separate from the app's own sessions.

Discovery. The service publishes protected-resource metadata and authorization-server metadata, so a client can find everything from the MCP URL alone.

Client identity. Clients identify themselves in one of two ways:

  • Client ID Metadata Documents (CIMD): the client ID is an HTTPS URL that serves the client's metadata. The connector fetches it only from an allow-list of AI platform hosts, only over HTTPS, without following redirects, and never from a private or loopback address. The result is cached briefly.
  • Dynamic Client Registration as a fallback. An unused registration expires, and one in use lives as long as its refresh tokens.

Redirects. Redirect URIs must match an allow-list exactly. Loopback redirects for local developer tools are allowed only when enabled, and then the port is ignored, as the native-app OAuth guidance recommends. The consent page names the app from the trusted redirect, never from anything the client claims about itself.

The flow. PKCE with S256 is required, and the authorization response includes the issuer parameter so a client can detect mix-up attacks. The sign-in steps are bound to the browser by a host-only cookie and a form token. The customer signs in with an SMS code or their password, through the customer API, then sees a consent screen that says which app is asking and what it will be able to do.

Tokens.

  • Authorization codes are single-use. Presenting a used code again revokes the grant it created.
  • Refresh tokens rotate on every use, and a reused refresh token also revokes the whole grant, because reuse is the classic signal of a stolen token.
  • Codes and tokens are stored only as hashes.
  • The customer's underlying Laayo session token is stored encrypted, never in plain text.
  • Revoking the connection also signs that Laayo web session out.

Rate limits that see the customer. AI platforms call from a small number of shared addresses, so a per-IP limit on the token endpoint would throttle every user together. That limit is set for shared egress. For the other direction, the connector forwards each customer's own IP to the customer API, signed so it cannot be spoofed, so the existing per-customer login limits apply to real customers and not to the connector as a whole.

Pages that work everywhere. The sign-in pages are server-rendered, work with JavaScript turned off, follow light and dark mode, and run under a strict content security policy. Static assets carry a version hash in their URLs, so a CDN can cache them for a long time without serving stale files after a change.

Trade-offs and what went wrong

Cash only, on purpose. Limiting chat orders to cash on delivery makes the connector less convenient for customers who prefer QR or card. I think it is the right trade for now. The payment flows are where the risk lives, and sending online payments through Laayo's checkout keeps that risk where it is already understood.

Testing an ordering tool with nowhere to order from. The test environment deliberately has every real restaurant closed, so the first end-to-end proof of cart, quote and place ran against a stub of the customer API. That proves the connector, not the integration. I then set up a dedicated test kitchen in the test environment, with fake contact details and a short menu, as the only open restaurant, with real notification tokens parked so no real phone could be reached. That gave a real end-to-end path with nothing able to leak out.

The reviewer account. Directory reviewers need to try ordering without a rider driving to a fake address. Specific reviewer accounts are marked as demo accounts. For them, place and cancel are simulated inside the connector while everything else is real. This needed care: the demo path must never be reachable by a normal account, and it must not look different to the model.

An icon cached as missing. After launch, the connector showed a generic icon instead of Laayo's. The service did not serve a root favicon at first. When I added it, the CDN in front was still serving its cached "not found" response, so the fix only appeared once that cache expired. Now the icons are part of the same versioned assets as everything else.

The first sign-in page looked generic. It worked, but it looked like a default template, and for many customers it is the first Laayo screen they see inside an AI app. I redesigned it to match Laayo's own login before the listing went live.

What I would do differently, and what is next

I would design the tools around the customer's steps from the first day. The earlier TypeScript version exposed more tools that mirrored API endpoints one to one, which left more of the sequencing to the model. Fewer, narrower tools with honest annotations worked better on every count.

I would also build the demo-account path and the test kitchen before writing the first tool, not after. Being able to run the real place flow end to end, safely, is what makes the rest of the work trustworthy.

Next:

  • An add-address tool, so a customer who has never ordered can finish their first order without opening the app.
  • Submission to ChatGPT's app directory. Its packaging format changed recently, so that work is mostly packaging around the same server.

Get new posts by email

One email when something new goes up. No tracking pixels, unsubscribe in one click.