The MCP Connector
How an outside AI assistant connects to KAP, acts as the engineer who connected it, and is stopped from doing anything they could not do themselves.
Two AI surfaces, pointing opposite ways
KAP has two connections to AI and they are easy to confuse, because both end in a conversation.
The first is the assistant inside the product — the chat panel in the workspace shell. KAP owns both ends of it: our web app talks to our AI engines over the AG-UI event contract, and the events that travel are ones we defined.
The second is this chapter. The MCP connector points the other way: an AI client we do not own — Claude, in a desktop app or a browser tab — connects to KAP over the Model Context Protocol, and KAP answers as a server. An integrity engineer can then ask their own assistant about their campaigns, and it reads the real data and records real decisions.
The distinction matters because the trust model inverts. In the first, we control the client and the model. In the second we control neither, so everything that decides what may happen has to live on our side of the wire.
The one rule the whole design rests on
Every tool call is forwarded to the same /api/... route the browser calls, carrying the caller’s own Supabase token.
There is no second data path and no second set of permissions. A tool does not query the database; it makes the request a screen would make, as the person who connected the connector, and Postgres row-level security decides what comes back — exactly as described in Access control. A viewer is refused the same way they are refused in the browser, and an audit row names the real person, because the token is their Supabase JWT.
Three consequences worth stating plainly:
- A connection cannot be scoped narrower than its user. Connecting the assistant gives it the reach of the person connecting it — not more, and not less. There is no read-only mode short of the tools themselves being reads.
- Nothing the assistant does is invisible. Writes go through the same handlers, so the same records are written, attributed the same way.
- Turning it off is one variable. The whole surface is dark unless
MCP_SERVER_ENABLEDistrue. It is on forkap-dev; prod and alpha stay off until the OAuth security review is signed off.
The surface has two halves, on purpose
| how membership is decided | what it reaches | |
|---|---|---|
| Curated tools | Hand-written, one entry per operation, in web/lib/mcp/manifest.ts |
35 tools, 10 of them writes |
| Spec-driven tools | Generated from swagger.yaml; nothing hand-maintained |
kap_api_list, kap_api_show, kap_api_call — 73 reads |
They exist for opposite reasons, and neither replaces the other.
The curated tier is a set of decisions. kap_asset_findings does not just forward a request — it resolves a tag, fetches the findings, and formats them the way an engineer reads them; kap_anomaly_gallery returns actual image blocks with the anomaly boxes drawn on. That composition, and the titles and descriptions that steer a model toward the right tool, is judgement a generator cannot supply. Membership is a decision too: a write appears here because somebody chose to expose it, with a reason on the record.
The spec-driven tier is a guarantee of coverage. Curation has one failure mode: a question the backend can answer, that no tool covers, looks from the outside exactly like a question the product cannot answer. The three kap_api_* tools close that gap by being generated from the specification, so they cannot cover less than the backend documents:
kap_api_list— the operations, filterable by family, by substring, or by kind, each marked read or write, callable or not, and flagged when a curated tool already covers it.kap_api_show— one operation in full: every parameter with its type, enum, default and whether it is required, plus the request body schema. This is the tool that makes the next one usable, because parameter names come from the specification and are often not the obvious ones —listImagestakesdataset_slug, notslug.kap_api_call— calls one of them and returns the JSON.
The generic tools are a fallback, not the front door. A curated tool answers its own question better — it composes several calls, formats the result, and its description tells the model when to reach for it. kap_api_list marks which operations are already covered for exactly this reason.
Three ways an image can belong to an asset — never conflate them
“What images are there of <tag>?” has three different, non-overlapping answers, and this tool surface — on both channels, the connector and Kawa, KAP’s in-app engine — computes all three. The failure mode is not lacking an answer; it is giving a true answer to a question the reader was not asking, because nothing said which of the three it was.
- Tag-matched — the photo’s own text tag names the asset, content-verified (a human or an OCR pass wrote that tag onto that specific image).
kap_list_images(tags: ...)on the connector; Kawa’skap_list_candidatescarries the same match per image asmatched_tag. The connector can record this confirmation —kap_confirm_image_asset_tag,confirm-gated, attributed to the engineer — but not withdraw it; rejecting evidence stays in the app. - Geo-linked — the drone was physically near the asset when the photo was taken (
image_asset_link, a proximity join). Unconfirmed by content: nothing about the image itself was checked, only where the camera was standing.kap_images_near_asseton the connector; Kawa’skap_search_images(equipment_tag: ...). - Evidence-cited — a specific finding names this exact image as its evidence (
evidence_image_ids).kap_asset_findingson both channels.
These are three different sets, not three views of one set. An asset’s tag-matched photos, its geo-linked photos, and the photos a finding actually cites as evidence can — and in production, do — disagree by a wide margin: a live comparison across both channels (docs/conversations/20260901_where_does_my_attention_need_to_go_mcp.html and its Kawa companion) found an asset whose finding cited two images that were not among its tag-matched set at all. A response that says only “8 images”, with no word for which of the three it counted, reads as an answer to whichever of the three questions the reader happened to be asking — usually the wrong one.
Every tool named above says which concept it returned, in its description and in the text of its answer — “N tag-matched image(s)”, “N geo-linked image(s)”, “N image(s) cited as evidence” — so a reader never has to guess or reverse engineer it from the field names. kap_list_assets’s ranking carries the same distinction into aggregate form: its per-asset image count is the geo-linked pool, not a census of confirmed evidence, and because a hovering drone can leave dozens of near-continuous frames in a single pass near one asset, it reports a capture-time-clustered distinct_visits_count alongside the raw count whenever the two differ, so a large number is never mistaken for that many separately-reviewed looks.
Kawa has the same tools
Kawa, KAP’s in-app engine, registers every connector tool it does not already have natively as a Kawa tool (ai/servers/kawa/mcp_bridge.py). Each call is forwarded to /api/mcp with the user’s own JWT, so the two channels share one implementation and cannot drift: the same reads, the same writes, the same confirm gate in the server, the same row-level security, and audit rows that name the same person. Kawa’s native kap_list_datasets and kap_list_candidates are kept in place of the connector’s copies because they emit the chat panel’s AG-UI events. What does not cross is image and interactive-view content: Kawa’s tool results are text, so each such block becomes a one-line note. On Cloud Run the connector is the web container in the same pod (KAWA_KAP_MCP_URL=http://localhost:3000/api/mcp).
What a photo should show: rendering from its camera
kap_render_from_image renders the plant from the camera of one photograph and returns the render beside the photo. The camera is the photo’s pose of record (v_image_pose: a landmark fit, else the structure-from-motion solve, else the drone’s gimbal), with its calibrated lens. It is the check behind a camera-direction link (pose_frustum): if the asset is not in the render, the photo is unlikely to show it. renderer: "cad" draws the Navisworks model; "scene" draws the Gaussian splat with the CAD mesh. Both are the existing renderCadModel and renderScene operations, called with image_id.
The same picture can be taken from anywhere. kap_render_from_camera takes an explicit camera — a WGS84 position with ellipsoidal height, a heading, a pitch, an optional roll, and the horizontal field of view — and renders the model from it, through the same two operations. hfov is required and never guessed: on the Polycarbon unit the calibrated lens is 65.05° where the photo metadata implies 73.74°, about 2° of apparent pose error. With tag, the CAD renderer draws only the objects carrying that equipment tag and the answer says how many matched, so a frame that matched nothing cannot pass for one that shows the asset. The answer echoes the camera it used, and carries the same settle flag.
And the picture the Assets page shows — the asset alone, framed — is one call away: kap_cad_view(tag, render: true). It takes the viewer object ids the page’s own locate step computes for the tag (the tagged nodes plus the untagged geometry assigned to them) and asks renderCadModel for frame=isolated, where the viewer isolates those objects and frames their bounds exactly as the panel does. No camera, no calibration — a dataset with a translated model and no world-to-CAD placement can still show its assets. The answer says how many objects were drawn and whether the scene settled. view: "normal" gives the same camera with the whole model drawn and nothing ghosted (show=all on the route: frame first, then show everything), and view: "both" returns the pair — the asset alone, then the asset in its surroundings, from one viewpoint.
Both render tools also answer “which equipment is in the picture” with with_assets: true: the render is asked to pick the CAD object under each point of a 12×9 grid, and the answer lists the equipment by share of the frame with its nearest range — occlusion-aware, because the viewer resolves what is in front. Where no render host exists, kap_render_from_camera falls back to the assets the camera is aimed toward by registered point (the Tier 3 pose-frustum test, lib/equipment/frustum.ts), and says so: an asset is a point while a column’s shell sits a median 13 m from it, and nothing in that test knows about occlusion.
Two things in the answer are there so it is not over-read:
- The pose source. A render is only as good as the pose. A
gimbalpose is drone metadata alone and can be several degrees off, and the answer says so. - Whether the scene settled. A frame captured before its tiles loaded can be missing geometry. The answer marks it, so absence is not read as evidence.
Rendering needs a GPU. The render worker refuses the software rasteriser (about 48 s a frame against 0.4 s on a GPU) unless KAP_RENDER_ALLOW_SOFTWARE=1 says otherwise; the web image carries a system Chromium for it. So on a deployment without a GPU renderer the tool answers that rendering is not available there, rather than an error that could be read as a bad pose or a missing model.
Looking at one photograph properly
Every gallery tool returns the cover-cropped thumbnail — enough to pick a photo out, not enough to read a gauge or an asset tag in it. kap_get_image returns one photograph whole: named by its filename inside a dataset (dataset_slug + filename, exact and case-insensitive; a substring that matches a single file is accepted, and an ambiguous one lists the candidates rather than guessing) or by its image id. It mints the signed URL through the same createImageUrls operation the viewer calls, fetches the file, and returns it aspect-preserved as an image block.
Two limits shape the answer, and both are stated in it:
- Size. A drone original is 4000 px and 5–10 MB, more than an MCP client accepts in one block. The tool bounds the longest side to
max_edge(default 1568 px, the size a model reads at full fidelity) and never enlarges a smaller file.original: truereturns the untouched bytes only under 4 MB; above that it says so and returns the bounded rendition instead. - Getting the file itself. The text carries a signed download URL for the original — short-lived, about an hour, the same URL the browser would use — so a reader who wants the file, not a look at it, has one.
A picture in, the file out
An engineer holding a picture — a frame cut into a slide, pasted into a report, sent in a message — wants the record behind it. kap_find_image_by_content answers “which of this dataset’s photographs is this” by perceptual hash: a 64-bit DCT fingerprint (lib/images/phash.ts) stored in images.metadata.phash when a thumbnail is generated, compared by Hamming distance through searchImagesByContent. A re-encoded or resized copy lands within a few bits; an unrelated photograph of the same plant sits around 30; a substantial crop or a rotation is not matched. It is “this is that file”, not “this shows the same thing”. The corollary is that the tool wants the photograph alone: a slide, a report page or a screenshot that carries the photo plus a title, margins or chrome hashes as a different picture and finds nothing. The T210 slide found its frame at 4 bits once cropped to the photo, and nothing at all whole (kap-dev, 2026-09-25); the tool description says so.
The picture has to reach the server, and an MCP client gives it two ways in. With image_url — a link or a data: URL — the tool searches at once and returns the matches with thumbnails. Without one, which is the usual case because a picture shown to the model in the conversation cannot be forwarded as bytes, the tool returns a short-lived UI resource: a drop zone (/api/mcp/embed/find-by-content, sealed like the gallery embed, the bearer never in the page) where the engineer puts the picture and sees the matches. Either way the answer says how many of the dataset’s images carry a hash at all, so an empty result over an unindexed dataset is never read as absence; web/scripts/phash-backfill.ts hashes the frames that predate the derivation.
What stands at a GPS point
The same placement that puts a camera into the model puts any point into it. kap_cad_query(near_lon, near_lat, near_h, radius_m) asks getCadObjectsNearPoint (GET /datasets/{slug}/cad/locate): the WGS84 point goes through the tile transform, the scene’s alignment offset and the landmark-fitted correction — cadFrame(...).pointToCad, now with both, so the answer and a render from that point agree — and the CAD objects whose centroids lie within the radius come back nearest first, grouped by equipment tag. No rendering, no GPU. Two things the answer states because they are easy to get wrong: the height is ellipsoidal, and a drone frame’s raw GPSAltitude is not that (per-flight offset); and distances are centroid-to-point, so a long pipe run reads far even when its wall is beside the point. ## Raw capture data stays behind the pose of record
A drone frame’s own metadata carries an altitude and a heading — GPSAltitude, the gimbal yaw — and both are wrong by a constant per flight: on Polycarbon one flight’s altitude sits ~12 m high and its early heading is off by a fixed angle (scripts/flight_offsets.py). The pose of record (image_poses) is the SfM solve aligned to GPS where one exists, and the drone’s own gimbal pose where it does not — flight C has no solve, so there the gimbal pose is the record. The rule is therefore not “hide the drone’s numbers” but “never hand them over as if they were the pose”: kap_render_from_image renders from the pose of record and names its source, flagging gimbal; kap_list_images answers with the record and the horizontal GPS fix and withholds altitude and heading with the reason; kap_list_candidates and kap_images_near_asset never carried them; kap_render_from_camera says not to build a camera from them. The generic kap_api_call listImages returns the row as the backend does, and the specification says the same about metadata.
What is refused, and where the refusal lives
A generic call tool is only as good as the things it declines to do. Each of these is checked before any request is built, so nothing reaches the backend:
| Refusal | Why |
|---|---|
| Every write | kap_api_call is reads only. Writes are exposed only as curated tools, so that a write through this connector is always one somebody chose |
| Withheld operations | 11 operations must never acquire a tool — deleting a dataset or a finding, and anything that grants access tiers. The list is in the manifest with a reason per entry, and a test fails if one acquires a tool |
| Cookie-only operations | Their handlers read the session cookie, which this seam does not hold, and answer a bearer with 200 and empty data. Allowing them would return a plausible lie rather than an error |
| Unknown parameters | Answered with the operation’s real parameter names, rather than a request that silently ignores the one you invented |
A missing confirm |
A tool that records an engineer’s judgement — deciding a finding — requires confirm: true, enforced server-side. A required field in a JSON Schema is a statement about a valid call, and nothing obliges a client to reject an invalid one |
One more guard applies to every result, curated or generic: secret-bearing fields are stripped on the way out, on both the success and the error path. GET /api/datasets returns live cloud-storage credentials to the app that needs them; no tool needs them, and none receives them.
How the specification becomes a tool surface
web/ has no YAML parser, and adding one would have created a second implementation of the specification’s semantics — the rules that decide whether an operation is reachable with a bearer, or reads the session cookie, or is a read at all. Those are security answers, and two copies of a security answer drift.
So the chain runs through the module that already owns those rules — the one kavai api uses:
web/public/api-docs/swagger.yaml the hand-maintained source
→ ai/src/kavai/web_api/spec.py the rules, written once
→ scripts/generate_mcp_operations.py computes every fact
→ web/lib/mcp/operations.generated.json the descriptor the tools dispatch over
Regenerate it with pixi run mcp-operations. The descriptor is generated — never hand-edited: swagger-drift.yml runs the generator with --check and fails the build on any difference, alongside the other derived consumers of the specification described in Backend API & Swagger.
This is the same discipline as the API Endpoint Inventory, and for the same reason: a hand-kept copy of a 168-operation surface drifts, and the only question is when somebody notices.
Adding a tool
For a question that deserves a first-class answer — a composed result, images, a formatted summary — add a curated tool in web/lib/mcp/manifest.ts:
- Add an entry to
CURATED_TOOLSnaming theoperationIdit forwards to, the path and query it builds, and a JSON Schema for its arguments. - Write the
descriptionfor the model, not for a developer: it is the only thing that decides whether the tool gets used, and for the right question. - For anything beyond forwarding, supply
invoke— it still calls only the backend, as the caller. - A write needs a reason it is exposed at all,
confirmin its required list, and a test.
For a question that simply needs data the backend already returns, add nothing. kap_api_call reaches it the moment the operation exists in the specification — which is also why the specification is the only place a route change gets recorded.
scripts/annotate_consumers.py records which components, engines and MCP tools reach each operation, and that evidence is what the retirement work acts on when it asks whether anything still uses an endpoint. The spec-driven tools declare no operationId, so they never count as a caller. “An assistant could call it” is not the same as “something calls it”, and treating it as such would make every endpoint look alive.
Where the decisions are recorded
The connector was built as a sequence of proposals, each carrying a decision and the reasoning behind it. When something here looks arbitrary, the answer is usually in one of them:
docs/proposals/20260816_remote_mcp_server.md |
The design: transport, auth, and why the read surface came first |
docs/proposals/20260825_findings_writes_through_the_connector.md |
Why writes exist, which ones, and what a connection cannot be scoped to |
docs/proposals/20260901_the_cli_surface_through_the_connector.md |
The spec-driven tools, and why they are reads only |
docs/plans/20260816_remote_mcp_server_execution.md |
The work packages and the risk gates that hold them |