diff --git a/.claude/STATUS.md b/.claude/STATUS.md
index fa51b36..50388aa 100644
--- a/.claude/STATUS.md
+++ b/.claude/STATUS.md
@@ -1,6 +1,6 @@
# STATUS — hal
-Last updated: 2026-07-24
+Last updated: 2026-07-28
## Current Focus
@@ -16,6 +16,7 @@ Last updated: 2026-07-24
- [ ] **Ops track — 11 runs Archon, 1 brief par repo** — runbook maître `docs/features/ops-runbook.md` (séquencement, commandes de lancement, étapes manuelles). Phase 1 purge+CI : `ops-1-purge-ci.md` dans hal, edifice, bluegreen-marketplace, renaud-marketplace, auth-gateway. Phase 2 : hal `ops-2-deploy-make.md`, bgm `ops-2-release-script.md`, rm `ops-2-release-improve-map.md` + `ops-2-briefing-headless.md`, ag `ops-2-deploy-script.md`. Phase 3 : `ops-3-night-shift.md` (copié dans bluegreen-ops au bootstrap). Les 3 fichiers `ops-phase-*.md` sont des stubs "superseded" à supprimer au commit.
## Done (current sprint)
+- [x] **#82 — Edifice tools artifact-callable (branche `archon/task-feat-artifact-callable-edifice-tools`) — 2026-07-28** — 2 nouveaux tools `hal-mcp` : `get_mission_context(mission_id)` renvoie le `context.json` complet **inline** (header + `observations[]` + `notes[]` + `photos[]` metadata, **zéro URL signée**) — shape portée field-for-field depuis `build_context.py` de bluegreen-marketplace ; `get_mission_photo(photo_id, max_width?)` renvoie une photo redimensionnée en base64 JPEG (magick-wasm `@0.0.41`, wasm fetché du CDN au cold start, cible ~300 Ko, image content block). 12 read tools reçoivent `annotations: { readOnlyHint: true }`, `push_mission_context` explicitement `readOnlyHint: false` — débloque le chargement d'un artifact Claude sans cascade de dialogs Allow/Deny (`anthropics/claude-code#57398`, closed not-planned). Fetch de `get_mission_with_assets` extrait dans `fetchMissionRaw()` **sans changement de contrat**. Aucun changement de schéma `edifice_*`. **Offline vert** : `deno check` + suite Deno (68 tests, dont 5 nouveaux : shape inline sans URL, tools/list readOnlyHint, no-clobber single-field, 2 error-paths photo) + `make test` (157 pytest). ⚠️ **Happy-path resize + 3 critères d'acceptation = à vérifier LIVE post-`make deploy`** contre la mission de référence `3f2bd270-fd5f-4d09-a64e-063a66348dc8` (le resize nécessite le wasm de 14,6 Mo, hors suite offline). Débloque **bluegreen-marketplace#50** Phases 2-3 ; bump `plugins/hal/.mcp.json` = follow-up downstream après deploy. Voir cross-repo-log 2026-07-28.
- [x] **#65 — CI migration smoke-test — PR #72 mergée, `main` vert — 2026-07-24** — job `migration-smoke` : charge `supabase/schema.snapshot.sql` (dump réel de prod, 218 Ko, schémas `auth`+`public`+`private`) dans un conteneur éphémère, puis applique les migrations que la branche ajoute. Lancé via Archon `archon-fix-github-issue`. **Le job produit par l'agent ne pouvait pas fonctionner** — 6 causes empilées, aucune visible en relecture, toutes trouvées en exécutant le job en local contre un vrai dump : `postgres:17` n'a ni les rôles cluster Supabase (`role "supabase_admin" does not exist`, ligne 18) ni ses extensions (`type "public.geometry"`, ligne 227) ; se connecter en `postgres` donne `permission denied for schema auth` (ligne 35) ; le schéma `auth` plus ancien de l'image fait que le `CREATE TABLE IF NOT EXISTS` du dump saute `auth.users` (`column "is_sso_user"`, ligne 1866) ; `--schema auth,public` oublie le schéma `private` de prod (ligne 4852). **La 6e est une faute de conception** : « appliquer la migration la plus récente » est impossible — le snapshot *étant* un dump de prod, il la contient déjà, donc `relation ... already exists` à chaque run, indéfiniment. Corrigé en appliquant ce que la branche **ajoute** (`git diff origin/ ...HEAD`, d'où `fetch-depth: 0`). Image = `public.ecr.aws/supabase/postgres:17.4.1.052`, version **exacte** de prod (`select version()`), la même que celle qu'utilise `supabase db dump` ; postgis/vector/pg_net y résolvent aux versions exactes de prod. **Test négatif exécuté** : un trigger réintroduisant la forme d'avant-fix (`AFTER INSERT ... WHEN (OLD.company_id IS DISTINCT FROM NEW.company_id)`) échoue bien avec `INSERT trigger's WHEN condition cannot reference OLD values` — **l'erreur exacte qui a atteint la prod via PR #60 est attrapée**. 4/4 critères d'acceptation. ⚠️ Le snapshot n'a pu être commité qu'après un détour : un dump de `public` embarquait une clé `service_role` en dur, inlinée dans `edifice_trigger_map_generation()` d'edifice depuis le 2026-05-29 — corrigé côté edifice (Vault puis secret dédié, clés compromises rotées) avant commit. **C'est le smoke-test qui a fait remonter la fuite.**
- [x] **PR #73 — `deno check` réparé sur le codec PNG — 2026-07-24** — `main` était rouge depuis `48f89fd` : la v10 déployée d'`edifice-map-generator`, commitée en `444fa3d`, ne passait pas le type-check (2 × `TS2345`). **Toutes les PR ouvertes héritaient de l'échec**, une PR testant son merge avec `main`. Deux causes de typage : (1) `CompressionStream`/`DecompressionStream` déclarent `writable: WritableStream`, plus large qu'`Uint8Array` — le helper demandait plus étroit que ce que ces classes fournissent ; (2) depuis **TypeScript 5.7** les tableaux typés sont génériques sur leur buffer, `Uint8Array` nu = `Uint8Array` (admet `SharedArrayBuffer`) alors que `BufferSource` exige `ArrayBufferView`. Corrigé en typant le chemin du codec `Uint8Array` **plutôt qu'avec un cast** — un `as BufferSource` aurait fait taire le compilateur sans énoncer la contrainte. Diff **types uniquement** : 3 signatures, aucune ligne exécutable, fonction déployée inchangée, aucun redéploiement impliqué. Vérifié avec la toolchain de la CI (image `denoland/deno`).
- [x] **Décision — guard `::uuid` non propagé, #67 fermée — 2026-07-23** — après merge de #62 (PR #70), remontée de tous les chemins d'écriture de `raw_app_meta_data.company_id` : `auth-gateway` `invite/form.tsx:101` et `users/[id]/edit/page.tsx:76` sont des `` peuplés depuis `public.companies` (valeur = UUID lu en base, pas de saisie libre) ; `edifice/webapp/app/api/invite/route.ts:43` réémet le `company_id` du JWT de l'admin invitant, lui-même issu du chemin précédent. **Aucun chemin ne permet une valeur malformée.** Prod : 7 lignes `auth.users`, 4 UUID valides + 3 NULL, 0 malformé. Conséquences : (a) **pas d'issue miroir côté edifice** pour `trg_provision_edifice_org` — le cas est inatteignable, la propager reviendrait à dupliquer un fix mal placé ; (b) **issue #67 fermée `not planned`** — le check ops périodique ne surveillait que le silence introduit par le guard lui-même (un `WHEN` ne peut pas `RAISE`), ce qui contredit la règle « fail fast, loudly / no workarounds » du CLAUDE.md ; (c) **guard #62 conservé** (déjà appliqué en prod, coût négligeable) mais on ne construit rien dessus. Seul vrai manque si validation souhaitée un jour : `auth-gateway` `POST /api/invite` ne valide ni le format ni l'existence de `company_id` — backend, bruyant, à la source. Note annexe : `auth-gateway/src/app/admin/actions.ts:65-70` jette l'erreur du lookup `companies` → `company_slug` peut passer à `null` en silence.
diff --git a/README.md b/README.md
index 1a9a8b9..c4a200d 100644
--- a/README.md
+++ b/README.md
@@ -29,7 +29,7 @@ documents — and acts from the chat the team already uses. The unit of value is
brain*, not the dashboard; the unit of distribution is the *marketplace skill*, not the
install wizard.
-**Status**: live in production. `hal-mcp` v0.2.1 deployed. 27 MCP tools across CRM, pipeline,
+**Status**: live in production. `hal-mcp` v0.2.1 deployed. 30 MCP tools across CRM, pipeline,
tasks/sprints, document vault, identity, and the Edifice field workflow. CRM, vault, and the
Edifice pull→push loop all run on Supabase `zgkvbjqlvebttbnkklpo`.
@@ -112,7 +112,10 @@ the firm's brain it needs for the question at hand.
## MCP tools (hal-mcp v0.2.1)
-27 tools. Reads are `list_*`/`get_*`; everything else mutates.
+30 tools. Reads are `list_*`/`get_*`; everything else mutates. Read tools carry
+`annotations: { readOnlyHint: true }` so a URL-less, permission-gated consumer (a Claude
+artifact) can call them on load without a permission dialog; `push_mission_context` is
+explicitly `readOnlyHint: false`.
**Identity**
- `whoami` — read-only probe: returns `user_email`, the user's `workspaces[]` (slug + role +
@@ -142,6 +145,8 @@ the firm's brain it needs for the question at hand.
**Edifice (conditional on `edifice.enabled`)**
- `list_edifice_missions` · `read_edifice_mission`
- `get_mission_with_assets` — notes + annotations + photo signed URLs
+- `get_mission_context` — full context.json shape inline, zero signed URLs (URL-less consumers)
+- `get_mission_photo` — single photo as resized base64 JPEG (image content block)
- `push_mission_context` — writes observations, building context, and optionally `photos[]` (crop_region + YOLO annotations); returns `updated`, `photos_updated`, `annotations_upserted`
---
diff --git a/docs/cross-repo-log.md b/docs/cross-repo-log.md
index fabdde7..fa68511 100644
--- a/docs/cross-repo-log.md
+++ b/docs/cross-repo-log.md
@@ -23,6 +23,16 @@ Keep entries ≤ 10 lines. Link outward; don't restate.
---
+## 2026-07-28 — Artifact-callable Edifice tools (get_mission_context, get_mission_photo, readOnlyHint)
+
+- **Repo**: hal
+- **Commit / PR**: `archon/task-feat-artifact-callable-edifice-tools` (issue #82)
+- **What shipped**: two new `hal-mcp` tools — `get_mission_context(mission_id)` returns the full `context.json`-shaped payload inline (header + `observations[]` + `notes[]` + `photos[]` metadata, **zero signed URLs**), and `get_mission_photo(photo_id, max_width?)` returns one photo as a resized base64 JPEG (magick-wasm, ~300 KB target). 12 read tools gained `annotations: { readOnlyHint: true }`; `push_mission_context` is now explicitly `readOnlyHint: false`. `get_mission_with_assets`'s fetch logic was extracted to a shared `fetchMissionRaw()` — its contract is unchanged. New npm dep `@imagemagick/magick-wasm@0.0.41` (WASM, fetched from CDN at cold start). No `edifice_*` schema change.
+- **Impact on the other repo**: unblocks **bluegreen-marketplace#50** (Edifice front as a Claude artifact), Phases 2–3. Once this Edge Function is deployed, the downstream repo must **bump `plugins/hal/.mcp.json`'s version** so consumers pick up the new tools — that bump lives in bluegreen-marketplace and is not part of this PR. `build_context.py` in bluegreen-marketplace is the source shape `get_mission_context` was ported from; keep them in sync if either changes.
+- **Follow-up**: deploy `hal-mcp` (`make deploy`), then live-verify the 3 acceptance criteria against reference mission `3f2bd270-fd5f-4d09-a64e-063a66348dc8`; then bump the downstream `.mcp.json` version.
+
+---
+
## 2026-07-24 — service_role key removed from the map trigger; edge function auth decoupled
- **Repo**: edifice (`87252d14c`) + hal (`444fa3d`, `48f89fd`, deployed)
diff --git a/docs/prd.md b/docs/prd.md
index 508fe9c..64d46ab 100644
--- a/docs/prd.md
+++ b/docs/prd.md
@@ -122,16 +122,18 @@ columns on `halcrm_missions`.
### 3.2. The interface — hal-mcp
-One Supabase Edge Function (`hal-mcp`, Deno, `@supabase/server`). 27 tools. **Reads via
+One Supabase Edge Function (`hal-mcp`, Deno, `@supabase/server`). 30 tools. **Reads via
`list_*`/`get_*`, writes via mutation tools** — there is no fat-context blob; the agent pulls
-exactly what a question needs.
+exactly what a question needs. Read tools carry `annotations: { readOnlyHint: true }` so a
+URL-less, permission-gated consumer (a Claude artifact) can call them on load without a
+permission dialog; `push_mission_context` is explicitly `readOnlyHint: false`.
- **Identity**: `whoami` (read-only probe — `user_email`, `workspaces[]`, server-resolved `default_workspace_slug`; doubles as connectivity check)
- **CRM**: `create_company`, `list_companies`, `create_contact`, `list_contacts`, `log_interaction`
- **Pipeline**: `list_stages`, `list_projects`, `create_project`, `update_project`, `update_project_stage`
- **Tasks/sprints**: `create_task`, `list_tasks`, `update_task` (partial; no status, no sprint), `update_task_status`, `create_sprint`, `list_sprints` (status filter), `update_sprint` (rename, status, dates), `assign_task_to_sprint`
- **Vault**: `save_document`, `list_documents`, `get_document`, `get_document_file`
-- **Edifice** (conditional): `list_edifice_missions`, `read_edifice_mission`, `get_mission_with_assets`, `push_mission_context`
+- **Edifice** (conditional): `list_edifice_missions`, `read_edifice_mission`, `get_mission_with_assets`, `get_mission_context` (full context.json inline, no signed URLs — for URL-less consumers), `get_mission_photo` (single photo as resized base64 JPEG), `push_mission_context`
**Task & sprint model** — three single-responsibility write verbs, so a skill always knows
which tool to reach for. A task has independent fields and changing one never touches another:
@@ -210,8 +212,12 @@ workspace see everything; fine-grained permissions are out of scope.
### 6.1. Shipped (production)
-- `hal-mcp` v0.2.1 — 27 tools, OAuth + API-key auth, RLS isolation. `whoami` +
+- `hal-mcp` v0.2.1 — 30 tools, OAuth + API-key auth, RLS isolation. `whoami` +
`workspace_members.is_default` for server-side default-workspace resolution.
+- **Artifact-callable Edifice tools (#82)** — `get_mission_context` (inline context.json
+ shape, zero signed URLs) + `get_mission_photo` (resized base64 JPEG) for consumers that
+ can't follow a URL (Claude artifacts). `readOnlyHint: true`/`false` annotations on all
+ 13 relevant tools unblock artifact loading without an Allow/Deny dialog cascade.
- **Task/sprint completeness (LastDev Loop 1)** — `update_task` (partial),
`list_sprints` (resolve current via `status="actuel"`), `priority` + `external_ref` on
tasks. `halcrm_tasks.document` renamed to `external_ref`. v38 froze the surface; v0.2.1 adds
diff --git a/supabase/functions/hal-mcp/deno.json b/supabase/functions/hal-mcp/deno.json
index 92491be..c6ac079 100644
--- a/supabase/functions/hal-mcp/deno.json
+++ b/supabase/functions/hal-mcp/deno.json
@@ -6,6 +6,7 @@
"@modelcontextprotocol/sdk": "npm:@modelcontextprotocol/sdk@1.25.3",
"@supabase/server/core": "npm:@supabase/server@1.1.0/core",
"zod": "npm:zod@3.25.28",
- "@supabase/supabase-js": "npm:@supabase/supabase-js@2.47.10"
+ "@supabase/supabase-js": "npm:@supabase/supabase-js@2.47.10",
+ "@imagemagick/magick-wasm": "npm:@imagemagick/magick-wasm@0.0.41"
}
}
diff --git a/supabase/functions/hal-mcp/deno.lock b/supabase/functions/hal-mcp/deno.lock
index 1dd2100..2b5c1e0 100644
--- a/supabase/functions/hal-mcp/deno.lock
+++ b/supabase/functions/hal-mcp/deno.lock
@@ -1,6 +1,7 @@
{
"version": "5",
"specifiers": {
+ "npm:@imagemagick/magick-wasm@0.0.41": "0.0.41",
"npm:@modelcontextprotocol/sdk@1.25.3": "1.25.3_zod@3.25.28",
"npm:@supabase/server@1.1.0": "1.1.0_@supabase+supabase-js@2.47.10",
"npm:@supabase/supabase-js@2.47.10": "2.47.10",
@@ -13,6 +14,9 @@
"hono"
]
},
+ "@imagemagick/magick-wasm@0.0.41": {
+ "integrity": "sha512-ywIISf4/8/6k9lLfHUIE/uGuZCJfDG+NEcHJ7OHvzr2w+uLCoK5cIBsp6KlpQfq/EppYiNM+NqKXFiUSbJvHZA=="
+ },
"@modelcontextprotocol/sdk@1.25.3_zod@3.25.28": {
"integrity": "sha512-vsAMBMERybvYgKbg/l4L1rhS7VXV1c0CtyJg72vwxONVX0l4ZfKVAnZEWTQixJGTzKnELjQ59e4NbdFDALRiAQ==",
"dependencies": [
@@ -646,6 +650,7 @@
},
"workspace": {
"dependencies": [
+ "npm:@imagemagick/magick-wasm@0.0.41",
"npm:@modelcontextprotocol/sdk@1.25.3",
"npm:@supabase/server@1.1.0",
"npm:@supabase/supabase-js@2.47.10",
diff --git a/supabase/functions/hal-mcp/index.test.ts b/supabase/functions/hal-mcp/index.test.ts
index 3514524..709e004 100644
--- a/supabase/functions/hal-mcp/index.test.ts
+++ b/supabase/functions/hal-mcp/index.test.ts
@@ -1056,6 +1056,280 @@ Deno.test("get_mission_with_assets — annotations fetch error folds into has_er
}
});
+// ---------------------------------------------------------------------------
+// get_mission_context — inline context.json shape, no signed URLs (#82)
+// ---------------------------------------------------------------------------
+
+Deno.test("get_mission_context — inline shape, no signed URLs, observations routed by type", async () => {
+ const STATIC_KEY = "test-static-key-abc123";
+ Deno.env.set("SUPABASE_SECRET_KEYS", JSON.stringify({ hal_api_key: STATIC_KEY }));
+
+ const project = { id: "m1", name: "Résidence Test", type: "diagnostic", mission_context: { client: "Syndic X" }, building_id: null };
+ const notes = [
+ { id: "n1", name: "Fissure", type: "disorder", description: "Grande fissure", location: "Façade Est", zone: "Ext", assessment: "2", recommendations: "Reprendre", metadata: {} },
+ { id: "n2", name: "Rappel", type: "note", description: "Contexte visite", metadata: {} },
+ ];
+ const photos = [
+ { id: "p1", original_filename: "f1.jpg", storage_path: "m1/f1.jpg", note_id: "n1", crop_region: null, created_at: "2026-01-01" },
+ ];
+ const annotations = [{ id: "a1", photo_id: "p1", note_id: "n1", x_center: 0.5, y_center: 0.5, width: 0.2, height: 0.2, photo_description: "crack" }];
+
+ const projectMock = { select: () => projectMock, eq: () => projectMock, maybeSingle: () => Promise.resolve({ data: project, error: null }) };
+ const notesMock = { select: () => notesMock, eq: () => notesMock, order: () => Promise.resolve({ data: notes, error: null }) };
+ const photosMock = { select: () => photosMock, eq: () => photosMock, order: () => Promise.resolve({ data: photos, error: null }) };
+ const annotationsMock = { select: () => annotationsMock, in: () => Promise.resolve({ data: annotations, error: null }) };
+ // deno-lint-ignore no-explicit-any
+ const dbStub = stub(_adminClient, "from", returnsNext([projectMock as any, notesMock as any, photosMock as any, annotationsMock as any]));
+
+ try {
+ const res = await fetchHandler(callTool(STATIC_KEY, "get_mission_context", { mission_id: "m1" }, 720));
+ const body = await parseMcpResponse(res);
+ assertEquals(body.result.isError, undefined);
+ const ctx = JSON.parse(body.result.content[0].text);
+ // header fields flattened at top level (no nested "header" key)
+ assertEquals(ctx.project_type, "diagnostic");
+ assertEquals(ctx.client, "Syndic X");
+ assertEquals(ctx.titre_service, "Résidence Test");
+ assertEquals("header" in ctx, false);
+ // observations routed by note.type === "disorder"
+ assertEquals(ctx.observations.length, 1);
+ assertEquals(ctx.observations[0].ref, "OBS-01");
+ assertEquals(ctx.observations[0].note_id, "n1");
+ assertEquals(ctx.observations[0].zone, "Ext");
+ assertEquals(ctx.observations[0].photos, ["f1.jpg"]);
+ // everything else → free notes
+ assertEquals(ctx.notes.length, 1);
+ assertEquals(ctx.notes[0].note_id, "n2");
+ // photo metadata + annotations present, but ZERO signed_url anywhere in the payload
+ assertEquals(ctx.photos.length, 1);
+ assertEquals(ctx.photos[0].annotations[0].id, "a1");
+ assertEquals(JSON.stringify(ctx).includes("signed_url"), false);
+ } finally {
+ dbStub.restore();
+ Deno.env.set("SUPABASE_SECRET_KEYS", DEFAULT_SECRET_KEYS);
+ }
+});
+
+Deno.test("get_mission_context — building present: residence/adresse/type_batiment/description_batiment from edifice_buildings", async () => {
+ const STATIC_KEY = "test-static-key-abc123";
+ Deno.env.set("SUPABASE_SECRET_KEYS", JSON.stringify({ hal_api_key: STATIC_KEY }));
+
+ const project = { id: "m1b", name: "Résidence Test", type: "diagnostic", mission_context: { client: "Syndic X" }, building_id: "b1" };
+ const building = { id: "b1", name: "Résidence Bellevue", address: "12 rue Foo (Bat A)", building_type: "Copropriété", description: "3 étages" };
+
+ const projectMock = { select: () => projectMock, eq: () => projectMock, maybeSingle: () => Promise.resolve({ data: project, error: null }) };
+ const buildingMock = { select: () => buildingMock, eq: () => buildingMock, maybeSingle: () => Promise.resolve({ data: building, error: null }) };
+ const notesMock = { select: () => notesMock, eq: () => notesMock, order: () => Promise.resolve({ data: [], error: null }) };
+ const photosMock = { select: () => photosMock, eq: () => photosMock, order: () => Promise.resolve({ data: [], error: null }) };
+ // deno-lint-ignore no-explicit-any
+ const dbStub = stub(_adminClient, "from", returnsNext([projectMock as any, buildingMock as any, notesMock as any, photosMock as any]));
+
+ try {
+ const res = await fetchHandler(callTool(STATIC_KEY, "get_mission_context", { mission_id: "m1b" }, 723));
+ const body = await parseMcpResponse(res);
+ const ctx = JSON.parse(body.result.content[0].text);
+ assertEquals(ctx.residence, "Résidence Bellevue");
+ assertEquals(ctx.adresse, "12 rue Foo"); // cleanAddress strips the parenthetical
+ assertEquals(ctx.description_batiment, "3 étages");
+ } finally {
+ dbStub.restore();
+ Deno.env.set("SUPABASE_SECRET_KEYS", DEFAULT_SECRET_KEYS);
+ }
+});
+
+Deno.test("get_mission_context — suivi_chantier project_type shape + reservation observations", async () => {
+ const STATIC_KEY = "test-static-key-abc123";
+ Deno.env.set("SUPABASE_SECRET_KEYS", JSON.stringify({ hal_api_key: STATIC_KEY }));
+ const project = { id: "m2", name: "Chantier Test", type: "suivi_chantier", mission_context: { client: "Syndic Y", batiments_visites: "Bat A, Bat B" }, building_id: null };
+ const notes = [
+ { id: "n1", name: "Réserve", type: "reservation", description: "Fissure visible", metadata: {} },
+ ];
+ const projectMock = { select: () => projectMock, eq: () => projectMock, maybeSingle: () => Promise.resolve({ data: project, error: null }) };
+ const notesMock = { select: () => notesMock, eq: () => notesMock, order: () => Promise.resolve({ data: notes, error: null }) };
+ const photosMock = { select: () => photosMock, eq: () => photosMock, order: () => Promise.resolve({ data: [], error: null }) };
+ // deno-lint-ignore no-explicit-any
+ const dbStub = stub(_adminClient, "from", returnsNext([projectMock as any, notesMock as any, photosMock as any]));
+ try {
+ const res = await fetchHandler(callTool(STATIC_KEY, "get_mission_context", { mission_id: "m2" }, 721));
+ const body = await parseMcpResponse(res);
+ const ctx = JSON.parse(body.result.content[0].text);
+ assertEquals(ctx.project_type, "suivi_chantier");
+ assertEquals(ctx.batiments_visites, "Bat A, Bat B");
+ assertEquals("residence" in ctx, true); // suivi_chantier shape, not devis/diagnostic
+ assertEquals(ctx.observations[0].ref, "V-01"); // suivi_chantier prefix, not OBS-
+ assertEquals("zone" in ctx.observations[0], false); // zone only added for diagnostic
+ } finally {
+ dbStub.restore();
+ Deno.env.set("SUPABASE_SECRET_KEYS", DEFAULT_SECRET_KEYS);
+ }
+});
+
+Deno.test("get_mission_context — devis project_type shape, default chiffrage, no observation routing", async () => {
+ const STATIC_KEY = "test-static-key-abc123";
+ Deno.env.set("SUPABASE_SECRET_KEYS", JSON.stringify({ hal_api_key: STATIC_KEY }));
+ const project = { id: "m3", name: "Devis Test", type: "devis", mission_context: { client: "Syndic Z" }, building_id: null };
+ const notes = [{ id: "n1", name: "Contexte", type: "note", description: "Notes", metadata: {} }];
+ const projectMock = { select: () => projectMock, eq: () => projectMock, maybeSingle: () => Promise.resolve({ data: project, error: null }) };
+ const notesMock = { select: () => notesMock, eq: () => notesMock, order: () => Promise.resolve({ data: notes, error: null }) };
+ const photosMock = { select: () => photosMock, eq: () => photosMock, order: () => Promise.resolve({ data: [], error: null }) };
+ // deno-lint-ignore no-explicit-any
+ const dbStub = stub(_adminClient, "from", returnsNext([projectMock as any, notesMock as any, photosMock as any]));
+ try {
+ const res = await fetchHandler(callTool(STATIC_KEY, "get_mission_context", { mission_id: "m3" }, 722));
+ const body = await parseMcpResponse(res);
+ const ctx = JSON.parse(body.result.content[0].text);
+ assertEquals(ctx.project_type, "devis");
+ assertEquals(ctx.chiffrage.length, 3);
+ assertEquals(ctx.chiffrage[0].prestation, "Déplacement terrain");
+ // devis has no OBSERVATION_TYPE entry -> every note is a free note
+ assertEquals(ctx.observations.length, 0);
+ assertEquals(ctx.notes.length, 1);
+ } finally {
+ dbStub.restore();
+ Deno.env.set("SUPABASE_SECRET_KEYS", DEFAULT_SECRET_KEYS);
+ }
+});
+
+// ---------------------------------------------------------------------------
+// get_mission_photo — error paths (#82). Happy-path resize needs the magick
+// wasm (14.6 MB CDN fetch) and is verified live post-deploy per the acceptance
+// criterion; these offline tests exercise the wiring before the resize call.
+// ---------------------------------------------------------------------------
+
+Deno.test("get_mission_photo — unknown photo_id returns errorResult", async () => {
+ const STATIC_KEY = "test-static-key-abc123";
+ Deno.env.set("SUPABASE_SECRET_KEYS", JSON.stringify({ hal_api_key: STATIC_KEY }));
+ // deno-lint-ignore no-explicit-any
+ const photoMock: any = { select: () => photoMock, eq: () => photoMock, maybeSingle: () => Promise.resolve({ data: null, error: null }) };
+ // deno-lint-ignore no-explicit-any
+ const s = stub(_adminClient, "from", returnsNext([photoMock as any]));
+ try {
+ const res = await fetchHandler(callTool(STATIC_KEY, "get_mission_photo", { photo_id: "nope" }, 750));
+ const body = await parseMcpResponse(res);
+ assertEquals(body.result.isError, true);
+ assertEquals(body.result.content[0].text.includes("not found"), true);
+ } finally {
+ s.restore();
+ Deno.env.set("SUPABASE_SECRET_KEYS", DEFAULT_SECRET_KEYS);
+ }
+});
+
+Deno.test("get_mission_photo — missing storage_path returns errorResult", async () => {
+ const STATIC_KEY = "test-static-key-abc123";
+ Deno.env.set("SUPABASE_SECRET_KEYS", JSON.stringify({ hal_api_key: STATIC_KEY }));
+ // deno-lint-ignore no-explicit-any
+ const photoMock: any = {
+ select: () => photoMock, eq: () => photoMock,
+ maybeSingle: () => Promise.resolve({ data: { storage_path: null, mime_type: "image/jpeg", width: 100, height: 100 }, error: null }),
+ };
+ // deno-lint-ignore no-explicit-any
+ const s = stub(_adminClient, "from", returnsNext([photoMock as any]));
+ try {
+ const res = await fetchHandler(callTool(STATIC_KEY, "get_mission_photo", { photo_id: "p2" }, 752));
+ const body = await parseMcpResponse(res);
+ assertEquals(body.result.isError, true);
+ assertEquals(body.result.content[0].text.includes("has no storage_path"), true);
+ } finally {
+ s.restore();
+ Deno.env.set("SUPABASE_SECRET_KEYS", DEFAULT_SECRET_KEYS);
+ }
+});
+
+Deno.test("get_mission_photo — storage download failure returns errorResult", async () => {
+ const STATIC_KEY = "test-static-key-abc123";
+ Deno.env.set("SUPABASE_SECRET_KEYS", JSON.stringify({ hal_api_key: STATIC_KEY }));
+ // deno-lint-ignore no-explicit-any
+ const photoMock: any = {
+ select: () => photoMock, eq: () => photoMock,
+ maybeSingle: () => Promise.resolve({ data: { storage_path: "m1/p1.jpg", mime_type: "image/jpeg", width: 2000, height: 1000 }, error: null }),
+ };
+ // deno-lint-ignore no-explicit-any
+ const s = stub(_adminClient, "from", returnsNext([photoMock as any]));
+ try {
+ await withStorageMock(
+ { from: (_b: string) => ({ download: () => Promise.resolve({ data: null, error: { message: "object not found" } }) }) },
+ async () => {
+ const res = await fetchHandler(callTool(STATIC_KEY, "get_mission_photo", { photo_id: "p1" }, 751));
+ const body = await parseMcpResponse(res);
+ assertEquals(body.result.isError, true);
+ assertEquals(body.result.content[0].text.includes("Photo download failed"), true);
+ },
+ );
+ } finally {
+ s.restore();
+ Deno.env.set("SUPABASE_SECRET_KEYS", DEFAULT_SECRET_KEYS);
+ }
+});
+
+// ---------------------------------------------------------------------------
+// tools/list — readOnlyHint annotations (#82)
+// ---------------------------------------------------------------------------
+
+Deno.test("tools/list — every list_*/get_* tool is readOnlyHint:true except documented write side effects", async () => {
+ const STATIC_KEY = "test-static-key-abc123";
+ Deno.env.set("SUPABASE_SECRET_KEYS", JSON.stringify({ hal_api_key: STATIC_KEY }));
+ try {
+ const res = await fetchHandler(new Request(BASE, {
+ method: "POST",
+ headers: { apikey: STATIC_KEY, "Content-Type": "application/json", Accept: "application/json, text/event-stream" },
+ body: JSON.stringify({ jsonrpc: "2.0", id: 730, method: "tools/list", params: {} }),
+ }));
+ const body = await parseMcpResponse(res);
+ const tools = body.result.tools as Array<{ name: string; annotations?: { readOnlyHint?: boolean } }>;
+ const byName = (n: string) => tools.find((t) => t.name === n);
+
+ // get_mission_with_assets writes an export blob to storage as a side effect — documented exception.
+ const WRITE_SIDE_EFFECT_EXCEPTIONS = new Set(["get_mission_with_assets"]);
+ for (const t of tools) {
+ const looksReadOnly = /^(list_|get_)/.test(t.name) && !WRITE_SIDE_EFFECT_EXCEPTIONS.has(t.name);
+ if (looksReadOnly) {
+ assertEquals(t.annotations?.readOnlyHint, true, `${t.name} looks read-only but is missing readOnlyHint:true`);
+ }
+ }
+
+ // Read tools whose names don't match the list_*/get_* prefix convention.
+ assertEquals(byName("read_edifice_mission")?.annotations?.readOnlyHint, true);
+ assertEquals(byName("whoami")?.annotations?.readOnlyHint, true);
+
+ assertEquals(byName("push_mission_context")?.annotations?.readOnlyHint, false);
+ } finally {
+ Deno.env.set("SUPABASE_SECRET_KEYS", DEFAULT_SECRET_KEYS);
+ }
+});
+
+// ---------------------------------------------------------------------------
+// push_mission_context — single-field no-clobber proof (#82 acceptance)
+// ---------------------------------------------------------------------------
+
+Deno.test("push_mission_context — single-field observation leaves sibling fields untouched", async () => {
+ const STATIC_KEY = "test-static-key-abc123";
+ Deno.env.set("SUPABASE_SECRET_KEYS", JSON.stringify({ hal_api_key: STATIC_KEY }));
+ let capturedFields: Record = {};
+ // deno-lint-ignore no-explicit-any
+ const chain: any = {
+ update: (fields: Record) => { capturedFields = fields; return chain; },
+ eq: () => chain,
+ select: () => Promise.resolve({ data: [{ id: "n1" }], error: null }),
+ };
+ // deno-lint-ignore no-explicit-any
+ const s = stub(_adminClient, "from", returnsNext([chain as any]));
+ try {
+ const res = await fetchHandler(callTool(STATIC_KEY, "push_mission_context", {
+ observations: [{ note_id: "n1", description: "new text" }],
+ }, 740));
+ const body = await parseMcpResponse(res);
+ assertEquals(body.result.isError, undefined);
+ // Exactly the sent field reaches .update() — nothing else.
+ assertEquals(capturedFields, { description: "new text" });
+ for (const k of ["location", "zone", "assessment", "recommendations", "metadata", "type"]) {
+ assertEquals(k in capturedFields, false, `${k} must not be sent on a single-field update`);
+ }
+ } finally {
+ s.restore();
+ Deno.env.set("SUPABASE_SECRET_KEYS", DEFAULT_SECRET_KEYS);
+ }
+});
+
Deno.test("push_mission_context — upserts crop_region + annotations; new vs existing rows", async () => {
const STATIC_KEY = "test-static-key-abc123";
Deno.env.set("SUPABASE_SECRET_KEYS", JSON.stringify({ hal_api_key: STATIC_KEY }));
diff --git a/supabase/functions/hal-mcp/index.ts b/supabase/functions/hal-mcp/index.ts
index b0e05f8..0a63848 100644
--- a/supabase/functions/hal-mcp/index.ts
+++ b/supabase/functions/hal-mcp/index.ts
@@ -2,6 +2,7 @@ import * as supabaseServer from "@supabase/server/core";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
import { z } from "zod";
+import { ImageMagick, initializeImageMagick, MagickFormat, MagickGeometry } from "@imagemagick/magick-wasm";
const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
@@ -129,6 +130,46 @@ function okResult(data: unknown) {
return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
}
+// --- image resize (magick-wasm) ---
+// The wasm binary (~14.6 MB) is fetched from the CDN once per isolate and the
+// init Promise is memoized so warm invocations skip it. Pinned to the imported
+// package version so the wasm and the JS bindings never drift.
+const MAGICK_WASM_URL = "https://cdn.jsdelivr.net/npm/@imagemagick/magick-wasm@0.0.41/dist/magick.wasm";
+let magickInit: Promise | null = null;
+function ensureMagick(): Promise {
+ if (!magickInit) {
+ magickInit = (async () => {
+ const res = await fetch(MAGICK_WASM_URL);
+ if (!res.ok) throw new Error(`magick.wasm fetch failed: ${res.status}`);
+ await initializeImageMagick(new Uint8Array(await res.arrayBuffer()));
+ })().catch((err) => {
+ magickInit = null; // don't pin a transient CDN failure for the isolate's lifetime
+ throw err;
+ });
+ }
+ return magickInit;
+}
+
+// Resize to at most maxWidth (aspect preserved, never upscaled) and re-encode JPEG q70.
+function resizeJpeg(input: Uint8Array, maxWidth: number): Uint8Array {
+ // deno-lint-ignore no-explicit-any
+ return ImageMagick.read(input, (image: any) => {
+ if (image.width > maxWidth) image.resize(new MagickGeometry(maxWidth, 0));
+ image.quality = 70;
+ return image.write(MagickFormat.Jpeg, (data: Uint8Array) => new Uint8Array(data));
+ });
+}
+
+// Base64-encode a byte array in chunks (String.fromCharCode(...large) overflows the stack).
+function toBase64(bytes: Uint8Array): string {
+ let binary = "";
+ const chunk = 0x8000;
+ for (let i = 0; i < bytes.length; i += chunk) {
+ binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
+ }
+ return btoa(binary);
+}
+
function newId(): string {
return crypto.randomUUID().replace(/-/g, "");
}
@@ -222,6 +263,7 @@ registerTool(
inputSchema: z.object({
mission_id: z.string().describe("UUID of edifice_projects row. Example: 2d3138cb-7bdb-4236-a29f-5ea51883b363"),
}),
+ annotations: { readOnlyHint: true },
},
async ({ mission_id }, extra: unknown) => {
const db = getDb(extra);
@@ -236,6 +278,268 @@ registerTool(
}
);
+// Raw mission fetch shared by get_mission_with_assets (signs URLs + exports) and
+// get_mission_context (returns inline, no URLs). Photos carry their grouped
+// annotations but NO signed_url — signing is the caller's job when needed.
+type FetchMissionRaw = {
+ // deno-lint-ignore no-explicit-any
+ project: any;
+ projectErr: string | null;
+ // deno-lint-ignore no-explicit-any
+ building: any;
+ building_error: string | null;
+ // deno-lint-ignore no-explicit-any
+ notes: any[];
+ notesErr: string | null;
+ // deno-lint-ignore no-explicit-any
+ photos: any[];
+ photosErr: string | null;
+ annotationsErr: string | null;
+};
+
+// deno-lint-ignore no-explicit-any
+async function fetchMissionRaw(db: any, mission_id: string): Promise {
+ const { data: project, error: pErr } = await db
+ .from("edifice_projects")
+ .select("id, name, status, type, mission_context, created_at, organization_ids, building_id")
+ .eq("id", mission_id)
+ .maybeSingle();
+ if (pErr || !project) {
+ return {
+ project: null, projectErr: pErr?.message ?? null,
+ building: null, building_error: null,
+ notes: [], notesErr: null, photos: [], photosErr: null, annotationsErr: null,
+ };
+ }
+
+ let building = null;
+ let building_error: string | null = null;
+ if (project.building_id) {
+ const { data: b, error: bErr } = await db
+ .from("edifice_buildings")
+ .select("id, name, address, building_type, description, latitude, longitude, building_2d_map_url")
+ .eq("id", project.building_id)
+ .maybeSingle();
+ building = b;
+ building_error = bErr?.message ?? null;
+ if (bErr) console.error(`fetchMissionRaw: building fetch failed for ${project.building_id}:`, bErr.message);
+ }
+
+ const { data: notes, error: notesErr } = await db
+ .from("edifice_notes")
+ .select("id, name, description, location, zone, assessment, recommendations, metadata, ref, type, display_order, created_at, updated_at")
+ .eq("project_id", mission_id)
+ .order("display_order", { ascending: true, nullsFirst: false });
+ if (notesErr) console.error(`fetchMissionRaw: notes fetch failed for mission ${mission_id}:`, notesErr.message);
+
+ const { data: photos, error: photosErr } = await db
+ .from("edifice_photos")
+ .select("id, original_filename, storage_path, note_id, crop_region, created_at")
+ .eq("project_id", mission_id)
+ .order("created_at", { ascending: true });
+ if (photosErr) console.error(`fetchMissionRaw: photos fetch failed for mission ${mission_id}:`, photosErr.message);
+
+ // deno-lint-ignore no-explicit-any
+ const photoIds = (photos ?? []).map((p: any) => p.id);
+ // deno-lint-ignore no-explicit-any
+ let annotations: any[] = [];
+ let annotationsErr: string | null = null;
+ if (photoIds.length > 0) {
+ const { data: ann, error: aErr } = await db
+ .from("edifice_annotations")
+ .select("id, photo_id, note_id, x_center, y_center, width, height, photo_description")
+ .in("photo_id", photoIds);
+ if (aErr) {
+ console.error(`fetchMissionRaw: annotations fetch failed for mission ${mission_id}:`, aErr.message);
+ annotationsErr = aErr.message;
+ } else {
+ annotations = ann ?? [];
+ }
+ }
+ // deno-lint-ignore no-explicit-any
+ const annotationsByPhoto: Record = {};
+ for (const ann of annotations) {
+ if (!annotationsByPhoto[ann.photo_id]) annotationsByPhoto[ann.photo_id] = [];
+ annotationsByPhoto[ann.photo_id].push(ann);
+ }
+ // deno-lint-ignore no-explicit-any
+ const photosWithAnn = (photos ?? []).map((photo: any) => ({
+ ...photo,
+ annotations: annotationsByPhoto[photo.id] ?? [],
+ }));
+
+ return {
+ project, projectErr: null,
+ building, building_error,
+ notes: notes ?? [], notesErr: notesErr?.message ?? null,
+ photos: photosWithAnn, photosErr: photosErr?.message ?? null,
+ annotationsErr,
+ };
+}
+
+// --- context.json shape builders (ported field-for-field from build_context.py
+// in bluegreen-marketplace; see docs/cross-repo-log.md 2026-07-28 for the sync note) ---
+
+function cleanAddress(addr: string): string {
+ if (!addr) return addr;
+ return addr.replace(/\s*\([^)]*\)/g, "").trim();
+}
+
+// deno-lint-ignore no-explicit-any
+function parseMissionContext(project: any): Record {
+ let mc = project?.mission_context ?? {};
+ if (typeof mc === "string") {
+ try {
+ mc = JSON.parse(mc);
+ } catch {
+ mc = {};
+ }
+ }
+ return (mc ?? {}) as Record;
+}
+
+// deno-lint-ignore no-explicit-any
+function buildHeader(project: any, building: any): Record {
+ const mc = parseMissionContext(project);
+ const project_type = project?.type || "diagnostic";
+ // deno-lint-ignore no-explicit-any
+ const g = (k: string) => (mc as any)[k];
+ const today = new Date().toISOString().slice(0, 10);
+
+ if (project_type === "suivi_chantier") {
+ return {
+ project_type: "suivi_chantier",
+ building_id: project?.building_id || "",
+ titre_service: g("titre_service") || project?.name || "",
+ client: g("client") || "",
+ residence: g("residence") || (building ? building.name : "") || "",
+ batiments_visites: g("batiments_visites") || "",
+ adresse: cleanAddress(g("adresse") || (building ? building.address : "") || ""),
+ code_postal_ville: g("code_postal_ville") || "",
+ ref_dossier: g("ref_dossier") || "",
+ date_visite: g("date_visite") || today,
+ participants: g("participants") || [],
+ objet_visite: g("objet_visite") || "",
+ synthese: g("synthese") || "",
+ conclusion: g("conclusion") || "",
+ };
+ }
+ if (project_type === "devis") {
+ return {
+ project_type: "devis",
+ building_id: project?.building_id || "",
+ titre_service: g("titre_service") || project?.name || "",
+ client: g("client") || "",
+ type_acteur: g("type_acteur") || "",
+ interlocuteur_nom: g("interlocuteur_nom") || "",
+ interlocuteur_role: g("interlocuteur_role") || "",
+ interlocuteur_contact: g("interlocuteur_contact") || "",
+ adresse: cleanAddress(g("adresse") || (building ? building.address : "") || ""),
+ type_batiment: (building ? building.building_type : "") || "",
+ annee_construction: g("annee_construction") || "",
+ nb_etages: g("nb_etages") || "",
+ description_batiment: (building ? building.description : "") || "",
+ declencheur: g("declencheur") || "",
+ livrable: g("livrable") || "",
+ urgence: g("urgence") || "Normal",
+ date_visite: g("date_visite") || today,
+ documents_fournis: g("documents_fournis") || [],
+ proposition_mission: g("proposition_mission") || "",
+ incertitudes: g("incertitudes") || "",
+ chiffrage: g("chiffrage") || [
+ { prestation: "Déplacement terrain", nb_heures: "", montant_ht: "" },
+ { prestation: "Visite terrain", nb_heures: "", montant_ht: "" },
+ { prestation: "Rédaction du rapport", nb_heures: "", montant_ht: "" },
+ ],
+ };
+ }
+ return {
+ project_type: "diagnostic",
+ building_id: project?.building_id || "",
+ titre_service: g("titre_service") || project?.name || "",
+ client: g("client") || "",
+ residence: g("residence") || (building ? building.name : "") || "",
+ adresse: cleanAddress(g("adresse") || (building ? building.address : "") || ""),
+ code_postal_ville: g("code_postal_ville") || "",
+ ref_dossier: g("ref_dossier") || "",
+ date_visite: g("date_visite") || today,
+ description_batiment: (building ? building.description : "") || "",
+ objet_visite: g("objet_visite") || "",
+ synthese: g("synthese") || "",
+ conclusion: g("conclusion") || "",
+ };
+}
+
+// note.type routed into observations[] only for project types listed here
+// (mirror build_context.py _OBSERVATION_TYPE); project types absent from this
+// map — e.g. "devis" — put every note into notes[] instead.
+const OBSERVATION_TYPE: Record = {
+ diagnostic: "disorder",
+ suivi_chantier: "reservation",
+};
+
+function basename(path: string): string {
+ const parts = path.split("/");
+ return parts[parts.length - 1];
+}
+
+// deno-lint-ignore no-explicit-any
+function buildObservationsAndNotes(notes: any[], photos: any[], project_type: string): { observations: any[]; notes: any[] } {
+ // deno-lint-ignore no-explicit-any
+ const photosByNote: Record = {};
+ for (const p of photos) {
+ const nid = p.note_id;
+ if (nid) (photosByNote[nid] ??= []).push(p);
+ }
+ const filenamesFor = (nid: string): string[] =>
+ (photosByNote[nid] ?? []).filter((p) => p.storage_path).map((p) => basename(p.storage_path));
+
+ const obsType = OBSERVATION_TYPE[project_type];
+ // deno-lint-ignore no-explicit-any
+ const observations: any[] = [];
+ // deno-lint-ignore no-explicit-any
+ const freeNotes: any[] = [];
+ let obsCounter = 0;
+
+ for (const note of notes) {
+ const nid = note.id || "";
+ const noteType = note.type || "note";
+ const filenames = filenamesFor(nid);
+ const photo = filenames.length > 0 ? filenames[0] : "";
+
+ if (obsType && noteType === obsType) {
+ obsCounter++;
+ const prefix = project_type === "suivi_chantier" ? "V" : "OBS";
+ // deno-lint-ignore no-explicit-any
+ const entry: any = {
+ ref: `${prefix}-${String(obsCounter).padStart(2, "0")}`,
+ note_id: nid,
+ name: note.name || "",
+ location: note.location || "",
+ description: note.description || "",
+ assessment: note.assessment || "",
+ recommendations: note.recommendations || "",
+ photos: filenames,
+ photo,
+ metadata: note.metadata || {},
+ };
+ if (project_type === "diagnostic") entry.zone = note.zone || "";
+ observations.push(entry);
+ } else {
+ freeNotes.push({
+ note_id: nid,
+ type: noteType,
+ name: note.name || "",
+ description: note.description || "",
+ photos: filenames,
+ photo,
+ metadata: note.metadata || {},
+ });
+ }
+ }
+ return { observations, notes: freeNotes };
+}
+
registerTool(
"get_mission_with_assets",
{
@@ -248,70 +552,16 @@ registerTool(
async ({ mission_id }, extra: unknown) => {
const db = getDb(extra);
const dbAdmin = getDbAdmin(extra);
- const { data: project, error: pErr } = await db
- .from("edifice_projects")
- .select("id, name, status, type, mission_context, created_at, organization_ids, building_id")
- .eq("id", mission_id)
- .maybeSingle();
- if (pErr) return errorResult(`Project fetch failed: ${pErr.message}`);
+ const { project, projectErr, building, building_error, notes, notesErr, photos, photosErr, annotationsErr } =
+ await fetchMissionRaw(db, mission_id);
+ if (projectErr) return errorResult(`Project fetch failed: ${projectErr}`);
if (!project) return errorResult("Project not found");
- let building = null;
- let building_error: string | null = null;
- if (project.building_id) {
- const { data: b, error: bErr } = await db
- .from("edifice_buildings")
- .select("id, name, address, building_type, description, latitude, longitude, building_2d_map_url")
- .eq("id", project.building_id)
- .maybeSingle();
- building = b;
- building_error = bErr?.message ?? null;
- if (bErr) console.error(`get_mission_with_assets: building fetch failed for ${project.building_id}:`, bErr.message);
- }
-
- const { data: notes, error: notesErr } = await db
- .from("edifice_notes")
- .select("id, name, description, location, zone, assessment, recommendations, metadata, ref, type, display_order, created_at, updated_at")
- .eq("project_id", mission_id)
- .order("display_order", { ascending: true, nullsFirst: false });
- if (notesErr) console.error(`get_mission_with_assets: notes fetch failed for mission ${mission_id}:`, notesErr.message);
-
- const { data: photos, error: photosErr } = await db
- .from("edifice_photos")
- .select("id, original_filename, storage_path, note_id, crop_region, created_at")
- .eq("project_id", mission_id)
- .order("created_at", { ascending: true });
- if (photosErr) console.error(`get_mission_with_assets: photos fetch failed for mission ${mission_id}:`, photosErr.message);
-
- // deno-lint-ignore no-explicit-any
- const photoIds = (photos ?? []).map((p: any) => p.id);
- // deno-lint-ignore no-explicit-any
- let annotations: any[] = [];
- let annotationsErr: string | null = null;
- if (photoIds.length > 0) {
- const { data: ann, error: aErr } = await db
- .from("edifice_annotations")
- .select("id, photo_id, note_id, x_center, y_center, width, height, photo_description")
- .in("photo_id", photoIds);
- if (aErr) {
- console.error(`get_mission_with_assets: annotations fetch failed for mission ${mission_id}:`, aErr.message);
- annotationsErr = aErr.message;
- } else {
- annotations = ann ?? [];
- }
- }
- // deno-lint-ignore no-explicit-any
- const annotationsByPhoto: Record = {};
- for (const ann of annotations) {
- if (!annotationsByPhoto[ann.photo_id]) annotationsByPhoto[ann.photo_id] = [];
- annotationsByPhoto[ann.photo_id].push(ann);
- }
-
const photosWithUrls = await Promise.all(
// deno-lint-ignore no-explicit-any
- (photos ?? []).map(async (photo: any) => {
+ photos.map(async (photo: any) => {
if (!photo.storage_path) {
- return { ...photo, annotations: annotationsByPhoto[photo.id] ?? [], signed_url: null, signed_url_error: null };
+ return { ...photo, signed_url: null, signed_url_error: null };
}
const { data: signed, error: urlErr } = await dbAdmin.storage
.from("edifice-photos")
@@ -319,7 +569,6 @@ registerTool(
if (urlErr) console.error(`get_mission_with_assets: signed URL failed for photo ${photo.id}:`, urlErr.message);
return {
...photo,
- annotations: annotationsByPhoto[photo.id] ?? [],
signed_url: signed?.signedUrl ?? null,
signed_url_error: urlErr?.message ?? null,
};
@@ -339,13 +588,13 @@ registerTool(
project,
building,
building_error,
- notes: notes ?? [],
- notes_error: notesErr?.message ?? null,
+ notes,
+ notes_error: notesErr,
photos: photosWithUrls,
- photos_error: photosErr?.message ?? null,
+ photos_error: photosErr,
annotations_error: annotationsErr,
photo_count: photosWithUrls.length,
- note_count: (notes ?? []).length,
+ note_count: notes.length,
});
const exportPath = `${mission_id}/${Date.now()}.json`;
@@ -365,15 +614,15 @@ registerTool(
const responsePayload: Record = {
download_url: signedExport.signedUrl,
- note_count: (notes ?? []).length,
+ note_count: notes.length,
photo_count: photosWithUrls.length,
expires_in: 300,
};
if (hasErrors) {
responsePayload.has_errors = true;
responsePayload.building_error = building_error;
- responsePayload.notes_error = notesErr?.message ?? null;
- responsePayload.photos_error = photosErr?.message ?? null;
+ responsePayload.notes_error = notesErr;
+ responsePayload.photos_error = photosErr;
responsePayload.annotations_error = annotationsErr;
return { isError: true as const, content: [{ type: "text" as const, text: JSON.stringify(responsePayload, null, 2) }] };
}
@@ -381,6 +630,68 @@ registerTool(
}
);
+registerTool(
+ "get_mission_context",
+ {
+ title: "Get Mission Context",
+ description: "Read an Edifice mission's full context INLINE — header fields + observations[] + notes[] + photos[] metadata, with NO signed URLs (no `building` 2D-map block — that requires local file I/O and is out of scope here). Built for consumers that cannot follow a URL (e.g. a Claude artifact under CSP). Photo bytes are served separately by get_mission_photo.",
+ inputSchema: z.object({
+ mission_id: z.string().describe("UUID of edifice_projects row. Example: 2d3138cb-7bdb-4236-a29f-5ea51883b363"),
+ }),
+ annotations: { readOnlyHint: true },
+ },
+ async ({ mission_id }, extra: unknown) => {
+ const db = getDb(extra);
+ const { project, projectErr, building, notes, notesErr, photos, photosErr, annotationsErr } =
+ await fetchMissionRaw(db, mission_id);
+ if (projectErr) return errorResult(`Project fetch failed: ${projectErr}`);
+ if (!project) return errorResult("Project not found");
+ if (notesErr) return errorResult(`Notes fetch failed: ${notesErr}`);
+ if (photosErr) return errorResult(`Photos fetch failed: ${photosErr}`);
+ if (annotationsErr) return errorResult(`Annotations fetch failed: ${annotationsErr}`);
+
+ const header = buildHeader(project, building);
+ const project_type = project.type || "diagnostic";
+ const { observations, notes: freeNotes } = buildObservationsAndNotes(notes, photos, project_type);
+ return okResult({ ...header, observations, notes: freeNotes, photos });
+ }
+);
+
+registerTool(
+ "get_mission_photo",
+ {
+ title: "Get Mission Photo",
+ description: "Return a single Edifice mission photo as a resized base64 JPEG (image content block), so a URL-less consumer (e.g. a Claude artifact) can render it. Server-side resize to max_width (default 1200px) at quality 70 — typically ~300 KB. Find photo IDs via get_mission_context's photos[].",
+ inputSchema: z.object({
+ photo_id: z.string().describe("UUID of an edifice_photos row"),
+ max_width: z.number().int().positive().optional().describe("Max width in px (aspect preserved, never upscaled). Default 1200."),
+ }),
+ annotations: { readOnlyHint: true },
+ },
+ async ({ photo_id, max_width }, extra: unknown) => {
+ const db = getDb(extra);
+ const { data: photo, error } = await db
+ .from("edifice_photos")
+ .select("storage_path")
+ .eq("id", photo_id)
+ .maybeSingle();
+ if (error) return errorResult(`get_mission_photo: ${error.message}`);
+ if (!photo) return errorResult(`Photo ${photo_id} not found`);
+ if (!photo.storage_path) return errorResult(`Photo ${photo_id} has no storage_path`);
+
+ const dbAdmin = getDbAdmin(extra);
+ const { data: blob, error: dlErr } = await dbAdmin.storage
+ .from("edifice-photos")
+ .download(photo.storage_path);
+ if (dlErr || !blob) return errorResult(`Photo download failed: ${dlErr?.message ?? "no data"}`);
+
+ await ensureMagick();
+ const raw = new Uint8Array(await blob.arrayBuffer());
+ const resized = resizeJpeg(raw, max_width ?? 1200);
+ return { content: [{ type: "image" as const, data: toBase64(resized), mimeType: "image/jpeg" }] };
+ }
+);
+
registerTool(
"push_mission_context",
{
@@ -413,6 +724,7 @@ registerTool(
})).optional(),
})).optional(),
}),
+ annotations: { readOnlyHint: false },
},
async ({ observations, building_id, building_description, photos }, extra: unknown) => {
const db = getDb(extra);
@@ -508,6 +820,7 @@ registerTool(
workspace_slug: z.string(),
kind: z.string().optional().describe("Project kind (e.g. 'opportunity'). Defaults to 'default'."),
}),
+ annotations: { readOnlyHint: true },
},
async ({ workspace_slug, kind }, extra: unknown) => {
const db = getDb(extra);
@@ -536,6 +849,7 @@ registerTool(
status: z.string().optional().describe("Filter by status (e.g. 'active', 'completed')"),
limit: z.number().int().optional().describe("Max results, default 50"),
}),
+ annotations: { readOnlyHint: true },
},
async ({ status, limit }, extra: unknown) => {
const db = getDb(extra);
@@ -562,6 +876,7 @@ registerTool(
stage: z.string().optional(),
tags: z.array(z.string()).optional().describe("Overlap filter (&&): returns projects that have ANY of the specified tags."),
}),
+ annotations: { readOnlyHint: true },
},
async ({ workspace_slug, kind, stage, tags }, extra: unknown) => {
const db = getDb(extra);
@@ -792,6 +1107,7 @@ registerTool(
inputSchema: z.object({
workspace_slug: z.string(),
}),
+ annotations: { readOnlyHint: true },
},
async ({ workspace_slug }, extra: unknown) => {
const db = getDb(extra);
@@ -859,6 +1175,7 @@ registerTool(
workspace_slug: z.string(),
company_id: z.string().optional(),
}),
+ annotations: { readOnlyHint: true },
},
async ({ workspace_slug, company_id }, extra: unknown) => {
const db = getDb(extra);
@@ -1033,6 +1350,7 @@ registerTool(
workspace_slug: z.string(),
status: z.enum(["passes", "dernier", "actuel", "suivant", "a_venir"]).optional(),
}),
+ annotations: { readOnlyHint: true },
},
async ({ workspace_slug, status }, extra: unknown) => {
const db = getDb(extra);
@@ -1222,6 +1540,7 @@ registerTool(
sprint_id: z.string().optional(),
tags: z.array(z.string()).optional().describe("Overlap filter (&&): returns tasks that have ANY of the specified tags."),
}),
+ annotations: { readOnlyHint: true },
},
async ({ workspace_slug, project_id, assignee_email, status, sprint_id, tags }, extra: unknown) => {
const db = getDb(extra);
@@ -1459,6 +1778,7 @@ registerTool(
expiring_before: z.string().optional().describe("ISO date: docs with valid_until on or before this date (includes already expired)"),
summary_only: z.boolean().optional().describe("Manifest mode: counts per domain + docs expiring within 30 days. Cannot be combined with filters."),
}),
+ annotations: { readOnlyHint: true },
},
async ({ workspace_slug, domain, kind, person_name, expiring_before, summary_only }, extra: unknown) => {
const db = getDb(extra);
@@ -1537,6 +1857,7 @@ registerTool(
title: "Get Document",
description: "Full fiche for one document — metadata + extracted facts + content_md. Answers most questions without downloading the file. Use get_document_file only when the human needs the actual binary.",
inputSchema: z.object({ workspace_slug: z.string(), slug: z.string() }),
+ annotations: { readOnlyHint: true },
},
async ({ workspace_slug, slug }, extra: unknown) => {
const db = getDb(extra);
@@ -1560,6 +1881,7 @@ registerTool(
title: "Get Document File",
description: "Short-lived signed download URL for a document's binary. TTL 3600s, 300s when sensitive=true. For sensitive documents, always confirm with the user before sharing the URL outside the conversation.",
inputSchema: z.object({ workspace_slug: z.string(), slug: z.string() }),
+ annotations: { readOnlyHint: true },
},
async ({ workspace_slug, slug }, extra: unknown) => {
const db = getDb(extra);
@@ -1602,6 +1924,7 @@ registerTool(
title: "Who Am I",
description:
"Read-only identity probe. Returns the authenticated user's email, the workspaces they belong to (slug + role + is_default flag), and the default workspace slug per the resolution rule (single membership → that one; multiple → the is_default=true row; otherwise null). Doubles as the connectivity probe: a successful call proves the MCP is reachable AND the user is provisioned. Requires user authentication; secret-mode callers (apikey) receive an error.",
+ annotations: { readOnlyHint: true },
},
async (extra: unknown) => {
const userClaims = getUserClaims(extra);