Skip to content

feat(hal-mcp): artifact-callable Edifice tools — inline context, photo bytes, readOnlyHint annotations #82

Description

@BluegReeno

Goal

Make hal-mcp callable from inside a Claude artifact. Today's Edifice tools assume a Claude-Code-side consumer that can follow a signed URL and tolerate permission prompts. An artifact can do neither. Three changes, all in supabase/functions/hal-mcp/index.ts.

Downstream consumer: bluegreen-marketplace#50 (Edifice front as an artifact). Phases 2–3 there are blocked on this issue.


1. get_mission_context — return the mission inline

The problem. get_mission_with_assets (index.ts:240) ends by writing an export to storage and returning a signed download_url, 300 s (index.ts:~359–367), and each photo carries a signed_url valid 3600 s. Empirically validated in a real Cowork session on 2026-07-27: an artifact's CSP blocks all outbound network — fetch and <img> against a fresh, non-expired Supabase signed URL both fail with TypeError. Connector calls are the artifact's only outbound channel. A signed URL is therefore dead weight there.

Ask. A tool returning the full mission context in the MCP result body, no URL indirection — same payload shape build_context.py consumes (flat snake_case header + observations[] + notes[] + photos[]), so the artifact adapter and the existing skill agree on one shape.

  • Either extend get_mission_with_assets with an inline: true mode, or add get_mission_context(mission_id). Prefer the new tool: get_mission_with_assets has a working contract with the /edifice skill and the report pipeline, and this is a different consumer.
  • Photo metadata inline; photo bytes are tool 2's job.
  • Keep the existing rule: mission_context is never surfaced for display.

2. get_mission_photo(photo_id, max_width?) — base64 bytes

  • Server-side resize, return base64 JPEG. Target ~1200 px q70 ≈ 300 KB.
  • Sizing constraint to respect: a rendered artifact page is capped at 16 MiB. At ~300 KB/photo the ceiling is roughly 50 photos — beyond that the artifact must page or fetch on demand. Full-resolution (~24 MB for the 12-photo reference mission) is over the limit and is not an option.

3. readOnlyHint annotations — the load-time permission cascade

The problem. anthropics/claude-code#57398 (closed not planned, stale): the artifact permission gate classifies a tool as "can modify data" at the tool level, not the call level, and fires on any call not triggered by a click or keypress. Result: N distinct load-time tool calls → N Allow/Deny dialogs, with no "Always allow", on every fresh session. The connector-level "Always Allow" setting is ignored in artifact context.

A front that reads its mission on load is exactly the shape that triggers this. The fix is upstream-proof and cheap: declare the reads as read-only.

  • Add annotations: { readOnlyHint: true } to the tool config object (2nd argument of registerTool, alongside title/description/inputSchema) for at minimum: list_edifice_missions, read_edifice_mission, get_mission_context, get_mission_photo.
  • Consider the same pass over the other obvious reads — list_tasks, list_sprints, list_documents, get_document, list_projects, list_contacts, list_companies, whoami — which would also benefit the command-center-quotidien artifact.
  • Explicitly annotate push_mission_context as a write (readOnlyHint: false), so no cache layer can ever replay it.

Second reason this matters: on the claude.ai artifact runtime, readOnlyHint: true is what enables result caching (staleTime/gcTime), and watchTool — the API arm for data that stays current — rejects tools annotated readOnlyHint: false. Without the annotation the artifact cannot use the live-refresh path at all.


4. Write-path robustness (push_mission_context)

The artifact contract states that server_unavailable and upstream_error are ambiguous outcomes for writes — a rejection is not proof the tool didn't run. The client is told to re-read state before re-issuing. That argues for making the write safely repeatable rather than asking the client to guess.

  • Verify push_mission_context (index.ts:385) is idempotent for a granular edit — one note, one requalification, one assessment — without clobbering fields the caller didn't send. Its observations[] entries already carry optional fields, so partial updates look supported; confirm with a test rather than by reading.
  • Consider returning the updated rows so a client can reconcile after an ambiguous failure without a second round trip.

Acceptance criteria

  • get_mission_context('3f2bd270-fd5f-4d09-a64e-063a66348dc8') returns the 10 notes and 12 photos of the reference mission with zero URL fields required for rendering.
  • get_mission_photo returns a photo under 400 KB base64, decodable to a valid JPEG.
  • tools/list shows readOnlyHint: true on every read tool listed above.
  • A test proves push_mission_context with a single-field observation leaves the other fields of that row untouched.
  • Edge Function deployed; plugins/hal/.mcp.json version bumped in bluegreen-marketplace in the same window.

Cross-repo hygiene

  • Log in docs/cross-repo-log.md; update .claude/STATUS.md.
  • No edifice_* schema change expected. If any: docs/schema-contract.json + plugin_version bumped in the same commit (contract rule).

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions