The AI seam
How an AI assistant reaches a Marka account — typed tools only, a 60-second signed identity token, writes that go through the same domain functions as the app, and no publish anywhere.
Marka runs an AI assistant that can search the marketplace, analyse photos, suggest prices and draft listings. It is a separate service from the web app, and the boundary between them is narrow on purpose: the assistant reaches your account through typed tools with validated inputs, carrying a signed, 60-second identity token, and every write it makes goes through the same domain functions the app itself uses.
Nothing on this seam can publish a listing.
The shape
Browser ──▶ apps/web ──(X-Marka-Identity: Bearer <60s JWT>)──▶ apps/ai (Mastra)
│ │
│ ├─ typed tools
└── MCP client ──(x-api-key)──▶ /api/mcp ─────────────┘Two entry points, one service behind them:
- CopilotKit, for the in-app assistant. The browser talks to the web app, never to the AI service directly.
- The hosted MCP gateway,
POST /api/mcp, for external MCP clients.
Both go through the web app, and the web app is what establishes identity. The AI service never sees your API key and never sees your session cookie.
The identity token
The web app mints a short-lived JWT for each request:
export async function mintIdentityToken(identity: GatewayIdentity): Promise<string> {
return new SignJWT({ role: identity.role })
.setProtectedHeader({ alg: "HS256" })
.setSubject(identity.userId)
.setIssuedAt()
.setExpirationTime("60s")
.sign(secretKey());
}HMAC-SHA256, symmetric, over a shared secret held by both services (the environment variable is
named AI_GATEWAY_SECRET on each side; both refuse to start without it). The payload is minimal:
sub is the user id, role is the role, plus the standard iat and exp. Time to live: 60
seconds.
It travels in a custom header rather than Authorization:
X-Marka-Identity: Bearer <jwt>That is not stylistic. On the MCP path the inbound Authorization header carries your raw Marka
API key, and that must not be forwarded to the AI service. Putting the minted identity in its own
header keeps the two credentials from ever being confused for one another.
The AI service verifies it on every request; anything invalid, expired or malformed resolves to no
identity, and the middleware answers 403. Tools that need to know who is asking read the verified
identity from the request context rather than from anything in the tool arguments — so an argument
claiming to be a different user id is simply not a thing the tools consult.
Replay window
Expiry is the only replay defence — there is no nonce or one-time-use marker, so a captured token is technically replayable within its 60-second life. It is a bearer credential over TLS between two services, scoped to one user, that expires in a minute. Worth knowing if you are threat modelling; not something a normal integration interacts with.
The tools
Every capability is a createTool definition with a zod input schema. There is no free-form
database access and no SQL-shaped tool; an assistant can only do what a tool models.
Eight tools exist in total. Six are exposed over MCP:
| Tool | What it does | Write? |
|---|---|---|
search_listings | Free-text search with optional min/max price, limit 1–50 (default 12) | no |
get_listing | Full public detail of one active listing | no |
list_categories | The complete category set with counts, in one call | no |
get_seller_info | A seller's public profile and active listing count | no |
create_listing_draft | Creates a draft listing | yes |
update_listing_draft | Edits an existing draft | yes |
Two more are available to the in-app assistant but not exposed over MCP:
| Tool | What it does |
|---|---|
analyze_photos | Vision analysis of 1–8 image URLs into structured listing fields |
suggest_price | Low/median/high price band from comparable listings, in minor units |
create_listing_draft takes title, description, categoryPath, condition, price (integer
minor units) as required fields, with currency defaulting to CAD, plus optional brand,
imageUrls, city and countryCode. It defaults new drafts to pickupEnabled: true and
directEnabled: true, so a draft is publishable without the seller having connected Stripe at all.
update_listing_draft accepts only title, description, price and imageUrls, and enforces
ownership directly — a draft that is not yours, or a listing that is not a draft, is a client error
rather than a silent no-op.
Writes go through the domain
This is the part that makes the tool boundary meaningful rather than cosmetic. The write tools do not touch the database directly — they call the same domain functions the web app calls, and persist through the same repositories:
create_listing_draft→createListingDraft(...)→createListingInDb(bundle)update_listing_draft→updateListingFields(...)→updateListingInDb(bundle)
So an AI-originated write gets the same validation, the same audit row and the same events as a
human-originated one — and the audit records it as such, because the actor type is ai and the
agent id distinguishes an MCP-originated write from an in-app one. Read tools query the database
directly, always scoped to status: "active" listings.
Nothing here can publish
There is no publish_listing tool on either MCP server, and the in-app agent is instructed not to
attempt it. The reason is the same one that makes publishing its own API endpoint: publishing runs
the activation guards,
makes the listing visible to buyers, and commits a seller to selling the thing. It stays a human
decision, taken in the Marka app or through an explicit POST /api/v1/listings/{id}/publish call
you make yourself.
What the write tools cost
The hosted MCP draft tools do not go through /api/v1, so they do not require the api_write
entitlement. They consume the AI copilot run quota instead — the same monthly allowance the
in-app assistant draws on: 3 runs per month on Free, unlimited on Pro and Business. Editing counts
as a run too, deliberately: otherwise the cap would be bypassable by creating once and editing
forever.
This produces the one genuine divergence between Marka's surfaces: a free-plan key can draft a
listing through the hosted MCP server, but cannot create one through POST /api/v1/listings.
The SDK's local MCP bridge is the other way round — it works entirely through /api/v1, so its
write tools carry the api_write requirement and answer 402 on a free plan. See
MCP overview for the full comparison.
Error containment
MCP servers echo thrown-error text back to the client, which turns any unhandled exception into an
information-disclosure channel. Every tool's execute is therefore wrapped: recognised errors
(client errors, domain errors, schema-validation failures) pass through with their real message,
because those are useful and safe. Anything else is reported internally and replaced with a fixed
string:
The tool failed to complete. The failure has been reported to Marka; please try again later.
So an unexpected failure on an MCP tool call will not hand you a stack trace or an internal identifier — that is the guard working, not a tool being unhelpful.
One known gap, on the CopilotKit path only
On the in-app CopilotKit streaming path, a mid-stream model-provider error can surface its raw message to the browser, because the streaming library offers no sanitisation seam at that point. It affects the in-app assistant, not the MCP surface, where the guard above is in place.
Where to look
| Concern | File |
|---|---|
| Tool definitions and input schemas | apps/ai/src/mastra/tools/*.ts |
| The MCP server and its tool registry | apps/ai/src/mastra/mcp.ts |
| Identity minting | apps/web/lib/ai-gateway.ts |
| Identity verification | apps/ai/src/mastra/identity.ts |
| The hosted MCP gateway | apps/web/app/api/mcp/route.ts |
| The generated tool reference | MCP → Tools |